Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
"""Thermograph API — grade recent local weather against ~45 years of climatology."""
|
|
|
|
|
|
import contextlib
|
|
|
|
|
|
import datetime
|
|
|
|
|
|
import hashlib
|
|
|
|
|
|
import hmac
|
2026-07-24 19:28:47 +00:00
|
|
|
|
import ipaddress
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
import json
|
|
|
|
|
|
import os
|
|
|
|
|
|
import queue
|
|
|
|
|
|
import threading
|
|
|
|
|
|
import time
|
|
|
|
|
|
|
2026-07-21 20:01:30 +00:00
|
|
|
|
import httpx
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
from fastapi import APIRouter, FastAPI, HTTPException, Query, Request, Response
|
2026-07-22 19:07:15 +00:00
|
|
|
|
from fastapi.concurrency import run_in_threadpool
|
2026-07-21 20:52:11 +00:00
|
|
|
|
from fastapi.middleware.cors import CORSMiddleware
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
from fastapi.middleware.gzip import GZipMiddleware
|
2026-07-22 19:07:35 +00:00
|
|
|
|
from fastapi.responses import JSONResponse, RedirectResponse
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
|
|
|
|
|
|
from accounts import api_accounts
|
2026-07-21 16:21:28 +00:00
|
|
|
|
from api import content_routes
|
daemon: move the Discord gateway and scheduler out of the web process into Go (#21)
The gateway bot and APScheduler were long-lived stateful I/O loops running
inside the async web app under a leader election. They move into a single Go
binary that owns ONLY that I/O -- websocket, RESUME, heartbeat, backoff, timers.
It owns no grading logic. Anything needing data calls back over a new
internal-only surface (/internal/discord/grade, /internal/jobs/*). Grading
depends on polars and the parquet cache; reimplementing it in Go would let the
bot's grades drift from the API's. The grade route returns gateway-ready JSON
and Go relays the bytes verbatim.
The binary ships in the backend image and runs as a second compose service off
the same tag, so the two ends of the /internal/* contract can never skew.
deploy.sh rolls daemon alongside backend -- without that the service would never
be created, since a single-service deploy uses --no-deps. It also probes the
image first and skips the daemon when rolling a tag that predates the binary:
infra tracks main while image tags are env-staged, so a host can legitimately be
asked to roll an older backend image, and creating the service anyway would
leave a container crash-looping on a missing binary.
replicas: 1 with order: stop-first replaces the leader election -- Discord
permits one gateway connection per bot token.
THERMOGRAPH_INTERNAL_TOKEN is optional: both ends derive it from
THERMOGRAPH_AUTH_SECRET via HMAC under a domain-separation label, so this needs
no new vault entry. The derivation is pinned to a shared cross-language test
vector asserted on both sides, so drift fails CI instead of 401ing every call.
Fail closed when neither secret is set.
Improvements over the Python: a close intended for RESUME uses 4000 rather than
1000 (Discord invalidates a session closed 1000, so the old default defeated its
own resume); MESSAGE_CREATE runs on a bounded worker pool; and a malformed HELLO
returns an error rather than a clean reconnect, which would otherwise reset
backoff and hot-loop against the gateway.
365 Python tests pass; Go build/vet/test -race clean; shellcheck 0 findings.
2026-07-23 22:49:54 +00:00
|
|
|
|
from api import internal_routes
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
from core import audit
|
|
|
|
|
|
from data import climate
|
|
|
|
|
|
from notifications import digest
|
|
|
|
|
|
from notifications import discord_interactions as discord_interactions_mod
|
|
|
|
|
|
from notifications import discord_link
|
2026-07-27 00:56:43 +00:00
|
|
|
|
from accounts import db, oauth
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
from data import grid
|
|
|
|
|
|
from core import metrics
|
|
|
|
|
|
from notifications import notify
|
|
|
|
|
|
import paths
|
|
|
|
|
|
from data import places
|
|
|
|
|
|
from core import singleton
|
|
|
|
|
|
from data import store
|
|
|
|
|
|
from accounts import users
|
2026-07-21 16:09:35 +00:00
|
|
|
|
from api import payloads
|
|
|
|
|
|
from accounts.schemas import UserCreate, UserRead, UserUpdate
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
|
|
|
|
|
|
# Everything (pages, assets, API) is served under this base path so the app can
|
|
|
|
|
|
# live at https://<host>/thermograph/ behind a shared domain. Override with the
|
|
|
|
|
|
# THERMOGRAPH_BASE env var. The frontend uses base-relative URLs, so it follows
|
|
|
|
|
|
# this automatically without hardcoding the prefix.
|
|
|
|
|
|
#
|
|
|
|
|
|
# THERMOGRAPH_BASE=/ (or empty) means the app owns the whole domain: BASE becomes
|
|
|
|
|
|
# "" so every route sits at the domain root ("/", "/calendar", "/api/v2/…") with
|
|
|
|
|
|
# no prefix and no leading double slash. A non-empty value keeps the sub-path form
|
|
|
|
|
|
# ("/thermograph").
|
|
|
|
|
|
_BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/")
|
|
|
|
|
|
BASE = f"/{_BASE}" if _BASE else ""
|
|
|
|
|
|
|
2026-07-21 20:01:30 +00:00
|
|
|
|
# The SSR content service (repo-split Stage 4): backend no longer renders the
|
|
|
|
|
|
# climate hub/city/month/records/glossary/home pages itself (frontend_ssr does,
|
|
|
|
|
|
# over the content API above), and this process now needs a way back TO it for
|
|
|
|
|
|
# every environment with no reverse proxy in front to do the split (LAN dev,
|
|
|
|
|
|
# bare-metal run.sh) -- see _proxy_to_frontend below. Required, fails loud: every
|
|
|
|
|
|
# environment is dual-service from this stage on, no single-service fallback.
|
|
|
|
|
|
FRONTEND_BASE = os.environ["THERMOGRAPH_FRONTEND_BASE_INTERNAL"]
|
|
|
|
|
|
_frontend_client = httpx.AsyncClient(base_url=FRONTEND_BASE, timeout=10.0)
|
|
|
|
|
|
|
Add the Swarm interim stack file, a pinnable TimescaleDB tag, and a Caddy health-gate (#235)
* Split web/worker duties with THERMOGRAPH_ROLE
Background work (the subscription notifier) is welded to the same process
that serves requests, so scaling the web tier to N replicas would also scale
notifier instances unless something restricts it further than leader
election alone.
Add THERMOGRAPH_ROLE (web|worker|all, default all - unchanged single-process
behavior). Every replica runs the same image; ROLE only gates whether a
process is allowed to own the notifier at all, layered on top of the
existing leader election: web replicas never start it even if they'd win
leader election, worker replicas start it if they win. The decision is
pulled into _should_run_notifier() so it's unit-testable without booting the
full app (DB init, places index, neighbor warmer).
Add a minimal /healthz liveness route (no DB/upstream I/O, not under BASE)
so a worker replica - which serves no real traffic - still has something
Swarm can health-check.
* Add the Swarm interim stack file, a pinnable TimescaleDB tag, and a Caddy health-gate
Three changes toward the hop-1 interim cutover, all inert until Track B
stands up the platform:
docker-stack.yml: the Swarm stack file for the interim cutover, distinct
from docker-compose.yml (today's plain-compose deploy, unaffected). Pulls a
pre-built image (IMAGE_TAG) instead of building in place; app/worker publish
no host port (127.0.0.1:8137:8137 has no Swarm equivalent - Swarm's routing
mesh publishes on 0.0.0.0, which would expose the plaintext app un-fronted),
reaching Caddy only over an MTU-lowered overlay network (VXLAN-over-WireGuard
needs a smaller MTU or large payloads silently stall); db is placement-
pinned to a labelled node; app/worker skip inline migrations
(RUN_MIGRATIONS=0) so the runbook's one-shot migrate task is the only thing
that ever runs Alembic; secrets are real Swarm secrets mounted at
/run/secrets, read by the entrypoint shim rather than plain env vars.
TIMESCALEDB_TAG: docker-compose.yml's db image now reads this (default
latest-pg18, today's behavior unchanged), wired through Terraform
(timescaledb_tag, default "latest-pg18") so it can actually be pinned to an
exact minor without hand-editing the host - required before any host of the
stack could replicate with another (a floating tag risks mismatched
extension minors, which blocks a physical replica and risks compressed-
chunk corruption on restore).
Caddy active health-gate: both the Terraform-rendered Caddyfile and the live
deploy/Caddyfile now health-check the app on the same cheap /healthz route
its own Docker HEALTHCHECK uses (now /healthz instead of the SSR homepage,
so it's cheap enough for a tight interval and works identically for a
worker replica, which serves no public traffic at all) - Caddy won't
forward into a container that's still booting or unhealthy.
Verified live: built and booted the real image via docker compose - both
containers report healthy via the new /healthz-based HEALTHCHECK, and GET /
still renders the full SSR homepage unchanged. Both Caddyfiles validated
with the real caddy binary. docker-stack.yml validated with docker compose
config (required-var guards fire with clear messages; secrets correctly
mount at /run/secrets/<name>, matching the entrypoint shim's mapping).
docker-compose.yml validated with and without TIMESCALEDB_TAG set, alongside
the existing openmeteo overlay. terraform validate + fmt clean.
2026-07-21 00:39:48 +00:00
|
|
|
|
# Which duties this process performs: "web" (serve requests, no notifier),
|
|
|
|
|
|
# "worker" (own the notifier, still serves requests today — see _lifespan), or
|
|
|
|
|
|
# "all" (both — today's single-process default, so an unset ROLE is unchanged
|
|
|
|
|
|
# behavior). Read once at import: toggling requires a process restart, same as
|
|
|
|
|
|
# every other env-driven constant here (BASE, WORKERS, ...).
|
|
|
|
|
|
ROLE = os.environ.get("THERMOGRAPH_ROLE", "all").strip().lower()
|
|
|
|
|
|
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
|
|
|
|
|
|
def _weather_fetch_error(e) -> HTTPException:
|
|
|
|
|
|
"""A clean, retryable 503 when weather data is temporarily unavailable; the
|
|
|
|
|
|
raw error as a 502 otherwise. The fetch layer classifies its own failures
|
|
|
|
|
|
(climate.WeatherUnavailable carries the user-facing text) so no message
|
|
|
|
|
|
parsing happens here; the is_rate_limit fallback covers a raw upstream 429
|
|
|
|
|
|
from a fetcher that didn't classify it."""
|
|
|
|
|
|
if isinstance(e, climate.WeatherUnavailable):
|
|
|
|
|
|
return HTTPException(status_code=503, detail=str(e))
|
|
|
|
|
|
if climate.is_rate_limit(e):
|
|
|
|
|
|
return HTTPException(status_code=503, detail=climate.limit_message(False))
|
|
|
|
|
|
return HTTPException(status_code=502, detail=f"weather data fetch failed: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-24 23:13:36 +00:00
|
|
|
|
def _parse_target_date(date: str | None, default: datetime.date) -> datetime.date:
|
|
|
|
|
|
"""Parse the ``date`` query param (YYYY-MM-DD) into a date, falling back to
|
|
|
|
|
|
``default`` when it's absent. A malformed or non-calendar date (e.g. 2026-13-40)
|
|
|
|
|
|
is a client error, not a crash: surface it as a 422 rather than let
|
|
|
|
|
|
fromisoformat's ValueError become a 500."""
|
|
|
|
|
|
if not date:
|
|
|
|
|
|
return default
|
|
|
|
|
|
try:
|
|
|
|
|
|
return datetime.date.fromisoformat(date[:10])
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
raise HTTPException(status_code=422,
|
|
|
|
|
|
detail=f"invalid date: {date!r} (expected YYYY-MM-DD)")
|
|
|
|
|
|
|
|
|
|
|
|
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
# --- background neighbor warming ---------------------------------------------
|
|
|
|
|
|
# /cell?neighbors=1 asks the server to warm the 8 grid cells around the request
|
|
|
|
|
|
# (the frontend used to fire 8 staggered prefetch requests, mirroring grid.py's
|
|
|
|
|
|
# snapping math in JS). One worker drains the queue so warms never stack; the
|
|
|
|
|
|
# hard guarantee matches prefetch=1 — a cell with no cached archive is skipped,
|
|
|
|
|
|
# so no weather-API quota is ever spent, and reverse_geocode itself paces the
|
|
|
|
|
|
# at-most-one Nominatim call per never-labeled cell (~1/s policy).
|
|
|
|
|
|
|
|
|
|
|
|
_WARM_TTL = 3600.0 # don't re-enqueue a cell within the hour
|
|
|
|
|
|
_WARM_SEEN: dict[str, float] = {} # cell_id -> last enqueue time
|
|
|
|
|
|
_warm_queue: "queue.Queue[dict]" = queue.Queue()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _warm_cell(cell: dict) -> None:
|
|
|
|
|
|
"""Materialize the history-derived slices for one cell — the same rows the
|
|
|
|
|
|
prefetch=1 bundle serves. Never fetches weather upstream."""
|
|
|
|
|
|
history = climate.load_cached_history(cell)
|
|
|
|
|
|
if history is None or history.is_empty():
|
|
|
|
|
|
return
|
|
|
|
|
|
place = climate.reverse_geocode(cell["center_lat"], cell["center_lon"])
|
2026-07-21 16:09:35 +00:00
|
|
|
|
token = payloads.history_token(history)
|
|
|
|
|
|
start_ts, end_ts = payloads.cal_span(history, None, None, 24)
|
|
|
|
|
|
cal_key = payloads.calendar_key(start_ts, end_ts, 24)
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
if store.get_payload("calendar", cell["id"], cal_key, token) is None:
|
|
|
|
|
|
store.put_payload("calendar", cell["id"], cal_key, token,
|
2026-07-21 16:09:35 +00:00
|
|
|
|
payloads.build_calendar(cell, history, start_ts, end_ts, 24, place))
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
last = history["date"].max()
|
2026-07-21 16:09:35 +00:00
|
|
|
|
day_key = payloads.day_key(last)
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
if store.get_payload("day", cell["id"], day_key, token) is None:
|
|
|
|
|
|
store.put_payload("day", cell["id"], day_key, token,
|
2026-07-21 16:09:35 +00:00
|
|
|
|
payloads.build_day(cell, history, last, place))
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _enqueue_neighbor_warming(cell: dict) -> None:
|
|
|
|
|
|
now = time.time()
|
|
|
|
|
|
for n in grid.neighbors(cell):
|
|
|
|
|
|
if now - _WARM_SEEN.get(n["id"], 0.0) < _WARM_TTL:
|
|
|
|
|
|
continue
|
|
|
|
|
|
_WARM_SEEN[n["id"]] = now
|
|
|
|
|
|
_warm_queue.put(n)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _neighbor_warmer() -> None:
|
|
|
|
|
|
while True:
|
|
|
|
|
|
cell = _warm_queue.get()
|
|
|
|
|
|
try:
|
|
|
|
|
|
_warm_cell(cell)
|
|
|
|
|
|
except Exception: # noqa: BLE001 - warming is best-effort, never fatal
|
|
|
|
|
|
pass
|
|
|
|
|
|
finally:
|
|
|
|
|
|
_warm_queue.task_done()
|
|
|
|
|
|
|
|
|
|
|
|
|
Add the Swarm interim stack file, a pinnable TimescaleDB tag, and a Caddy health-gate (#235)
* Split web/worker duties with THERMOGRAPH_ROLE
Background work (the subscription notifier) is welded to the same process
that serves requests, so scaling the web tier to N replicas would also scale
notifier instances unless something restricts it further than leader
election alone.
Add THERMOGRAPH_ROLE (web|worker|all, default all - unchanged single-process
behavior). Every replica runs the same image; ROLE only gates whether a
process is allowed to own the notifier at all, layered on top of the
existing leader election: web replicas never start it even if they'd win
leader election, worker replicas start it if they win. The decision is
pulled into _should_run_notifier() so it's unit-testable without booting the
full app (DB init, places index, neighbor warmer).
Add a minimal /healthz liveness route (no DB/upstream I/O, not under BASE)
so a worker replica - which serves no real traffic - still has something
Swarm can health-check.
* Add the Swarm interim stack file, a pinnable TimescaleDB tag, and a Caddy health-gate
Three changes toward the hop-1 interim cutover, all inert until Track B
stands up the platform:
docker-stack.yml: the Swarm stack file for the interim cutover, distinct
from docker-compose.yml (today's plain-compose deploy, unaffected). Pulls a
pre-built image (IMAGE_TAG) instead of building in place; app/worker publish
no host port (127.0.0.1:8137:8137 has no Swarm equivalent - Swarm's routing
mesh publishes on 0.0.0.0, which would expose the plaintext app un-fronted),
reaching Caddy only over an MTU-lowered overlay network (VXLAN-over-WireGuard
needs a smaller MTU or large payloads silently stall); db is placement-
pinned to a labelled node; app/worker skip inline migrations
(RUN_MIGRATIONS=0) so the runbook's one-shot migrate task is the only thing
that ever runs Alembic; secrets are real Swarm secrets mounted at
/run/secrets, read by the entrypoint shim rather than plain env vars.
TIMESCALEDB_TAG: docker-compose.yml's db image now reads this (default
latest-pg18, today's behavior unchanged), wired through Terraform
(timescaledb_tag, default "latest-pg18") so it can actually be pinned to an
exact minor without hand-editing the host - required before any host of the
stack could replicate with another (a floating tag risks mismatched
extension minors, which blocks a physical replica and risks compressed-
chunk corruption on restore).
Caddy active health-gate: both the Terraform-rendered Caddyfile and the live
deploy/Caddyfile now health-check the app on the same cheap /healthz route
its own Docker HEALTHCHECK uses (now /healthz instead of the SSR homepage,
so it's cheap enough for a tight interval and works identically for a
worker replica, which serves no public traffic at all) - Caddy won't
forward into a container that's still booting or unhealthy.
Verified live: built and booted the real image via docker compose - both
containers report healthy via the new /healthz-based HEALTHCHECK, and GET /
still renders the full SSR homepage unchanged. Both Caddyfiles validated
with the real caddy binary. docker-stack.yml validated with docker compose
config (required-var guards fire with clear messages; secrets correctly
mount at /run/secrets/<name>, matching the entrypoint shim's mapping).
docker-compose.yml validated with and without TIMESCALEDB_TAG set, alongside
the existing openmeteo overlay. terraform validate + fmt clean.
2026-07-21 00:39:48 +00:00
|
|
|
|
def _should_run_notifier() -> bool:
|
|
|
|
|
|
"""Whether this process should start the subscription-notifier thread: ROLE must
|
|
|
|
|
|
permit it, and it must win the leader election. Split out from _lifespan so this
|
|
|
|
|
|
decision is unit-testable without booting the full app (DB init, places index,
|
|
|
|
|
|
neighbor warmer) — see tests/web/test_role.py."""
|
|
|
|
|
|
return ROLE in ("worker", "all") and singleton.claim_leader()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-25 04:13:47 +00:00
|
|
|
|
_HEARTBEAT_STOP = threading.Event()
|
|
|
|
|
|
_HEARTBEAT_THREAD: threading.Thread | None = None
|
|
|
|
|
|
HEARTBEAT_INTERVAL = float(os.environ.get("THERMOGRAPH_HEARTBEAT_INTERVAL", "300")) # 5 min
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _heartbeat_loop() -> None:
|
|
|
|
|
|
metrics.record_heartbeat(ROLE, HEARTBEAT_INTERVAL)
|
|
|
|
|
|
audit.log_heartbeat(ROLE, HEARTBEAT_INTERVAL)
|
|
|
|
|
|
while not _HEARTBEAT_STOP.wait(HEARTBEAT_INTERVAL):
|
|
|
|
|
|
metrics.record_heartbeat(ROLE, HEARTBEAT_INTERVAL)
|
|
|
|
|
|
audit.log_heartbeat(ROLE, HEARTBEAT_INTERVAL)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _start_heartbeat() -> None:
|
|
|
|
|
|
"""Process-level liveness beat, tagged daemon=ROLE, independent of whether this
|
|
|
|
|
|
process also runs the notifier. web's and worker's own stdout is near-silent by
|
|
|
|
|
|
design (real logging goes to the app-json file source; worker's Docker/stdout
|
|
|
|
|
|
stream is dropped entirely by Alloy), so container-log-silence alerting can't
|
|
|
|
|
|
tell a live process from a dead one for either role — this heartbeat is the
|
|
|
|
|
|
signal observability actually alerts on instead. Deliberately unguarded across
|
|
|
|
|
|
every uvicorn worker process and every autoscaled replica: a beat is a single
|
|
|
|
|
|
local file append (no upstream quota, no DB, no lock), so duplicate beats are
|
|
|
|
|
|
harmless, and only the process actually being dead stops them — see
|
|
|
|
|
|
_should_run_notifier's leader election for the contrasting case where
|
|
|
|
|
|
duplication would be a real cost."""
|
|
|
|
|
|
global _HEARTBEAT_THREAD
|
|
|
|
|
|
if os.environ.get("THERMOGRAPH_ENABLE_HEARTBEAT", "1") == "0" or _HEARTBEAT_THREAD is not None:
|
|
|
|
|
|
return
|
|
|
|
|
|
_HEARTBEAT_STOP.clear()
|
|
|
|
|
|
_HEARTBEAT_THREAD = threading.Thread(target=_heartbeat_loop, name=f"heartbeat-{ROLE}", daemon=True)
|
|
|
|
|
|
_HEARTBEAT_THREAD.start()
|
|
|
|
|
|
|
|
|
|
|
|
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
@contextlib.asynccontextmanager
|
|
|
|
|
|
async def _lifespan(app):
|
|
|
|
|
|
# Warm the local place-name index (/suggest's typo tolerance) in the
|
|
|
|
|
|
# background; the app boots and serves fine without it — suggestions just
|
|
|
|
|
|
# fall back to the upstream geocoder until it's ready. A server-startup
|
|
|
|
|
|
# hook (not import time) so offline importers (tests, the migrate script's
|
|
|
|
|
|
# dependencies) don't kick off a GeoNames download. The neighbor warmer is
|
|
|
|
|
|
# also a server concern: enqueues before startup just wait in the queue.
|
|
|
|
|
|
# Create the account DB tables (users/sessions/subscriptions/notifications)
|
|
|
|
|
|
# before serving — a fast no-op once they exist.
|
|
|
|
|
|
await db.create_db_and_tables()
|
|
|
|
|
|
places.start_loading()
|
|
|
|
|
|
threading.Thread(target=_neighbor_warmer, name="neighbor-warmer", daemon=True).start()
|
2026-07-25 04:13:47 +00:00
|
|
|
|
_start_heartbeat()
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
# Background subscription evaluator. Its periodic sweep fetches upstream on a timer
|
2026-07-21 00:07:52 +00:00
|
|
|
|
# (not per request), so with multiple workers — or multiple *hosts* under Swarm —
|
|
|
|
|
|
# it must run in exactly one: elect a leader (see singleton.claim_leader, which
|
|
|
|
|
|
# picks a host-local flock or a cluster-wide Postgres advisory lock from env).
|
|
|
|
|
|
# Unset (single worker / dev / tests) => always the leader. The neighbor warmer
|
Add the Swarm interim stack file, a pinnable TimescaleDB tag, and a Caddy health-gate (#235)
* Split web/worker duties with THERMOGRAPH_ROLE
Background work (the subscription notifier) is welded to the same process
that serves requests, so scaling the web tier to N replicas would also scale
notifier instances unless something restricts it further than leader
election alone.
Add THERMOGRAPH_ROLE (web|worker|all, default all - unchanged single-process
behavior). Every replica runs the same image; ROLE only gates whether a
process is allowed to own the notifier at all, layered on top of the
existing leader election: web replicas never start it even if they'd win
leader election, worker replicas start it if they win. The decision is
pulled into _should_run_notifier() so it's unit-testable without booting the
full app (DB init, places index, neighbor warmer).
Add a minimal /healthz liveness route (no DB/upstream I/O, not under BASE)
so a worker replica - which serves no real traffic - still has something
Swarm can health-check.
* Add the Swarm interim stack file, a pinnable TimescaleDB tag, and a Caddy health-gate
Three changes toward the hop-1 interim cutover, all inert until Track B
stands up the platform:
docker-stack.yml: the Swarm stack file for the interim cutover, distinct
from docker-compose.yml (today's plain-compose deploy, unaffected). Pulls a
pre-built image (IMAGE_TAG) instead of building in place; app/worker publish
no host port (127.0.0.1:8137:8137 has no Swarm equivalent - Swarm's routing
mesh publishes on 0.0.0.0, which would expose the plaintext app un-fronted),
reaching Caddy only over an MTU-lowered overlay network (VXLAN-over-WireGuard
needs a smaller MTU or large payloads silently stall); db is placement-
pinned to a labelled node; app/worker skip inline migrations
(RUN_MIGRATIONS=0) so the runbook's one-shot migrate task is the only thing
that ever runs Alembic; secrets are real Swarm secrets mounted at
/run/secrets, read by the entrypoint shim rather than plain env vars.
TIMESCALEDB_TAG: docker-compose.yml's db image now reads this (default
latest-pg18, today's behavior unchanged), wired through Terraform
(timescaledb_tag, default "latest-pg18") so it can actually be pinned to an
exact minor without hand-editing the host - required before any host of the
stack could replicate with another (a floating tag risks mismatched
extension minors, which blocks a physical replica and risks compressed-
chunk corruption on restore).
Caddy active health-gate: both the Terraform-rendered Caddyfile and the live
deploy/Caddyfile now health-check the app on the same cheap /healthz route
its own Docker HEALTHCHECK uses (now /healthz instead of the SSR homepage,
so it's cheap enough for a tight interval and works identically for a
worker replica, which serves no public traffic at all) - Caddy won't
forward into a container that's still booting or unhealthy.
Verified live: built and booted the real image via docker compose - both
containers report healthy via the new /healthz-based HEALTHCHECK, and GET /
still renders the full SSR homepage unchanged. Both Caddyfiles validated
with the real caddy binary. docker-stack.yml validated with docker compose
config (required-var guards fire with clear messages; secrets correctly
mount at /run/secrets/<name>, matching the entrypoint shim's mapping).
docker-compose.yml validated with and without TIMESCALEDB_TAG set, alongside
the existing openmeteo overlay. terraform validate + fmt clean.
2026-07-21 00:39:48 +00:00
|
|
|
|
# above stays per-worker: each drains its own request-fed queue. ROLE further
|
|
|
|
|
|
# restricts this to processes that are allowed to own it at all — "web" replicas
|
|
|
|
|
|
# never start it even if they'd otherwise win the leader election, so a stateless
|
|
|
|
|
|
# N-replica web tier can scale without also scaling notifier instances.
|
daemon: move the Discord gateway and scheduler out of the web process into Go (#21)
The gateway bot and APScheduler were long-lived stateful I/O loops running
inside the async web app under a leader election. They move into a single Go
binary that owns ONLY that I/O -- websocket, RESUME, heartbeat, backoff, timers.
It owns no grading logic. Anything needing data calls back over a new
internal-only surface (/internal/discord/grade, /internal/jobs/*). Grading
depends on polars and the parquet cache; reimplementing it in Go would let the
bot's grades drift from the API's. The grade route returns gateway-ready JSON
and Go relays the bytes verbatim.
The binary ships in the backend image and runs as a second compose service off
the same tag, so the two ends of the /internal/* contract can never skew.
deploy.sh rolls daemon alongside backend -- without that the service would never
be created, since a single-service deploy uses --no-deps. It also probes the
image first and skips the daemon when rolling a tag that predates the binary:
infra tracks main while image tags are env-staged, so a host can legitimately be
asked to roll an older backend image, and creating the service anyway would
leave a container crash-looping on a missing binary.
replicas: 1 with order: stop-first replaces the leader election -- Discord
permits one gateway connection per bot token.
THERMOGRAPH_INTERNAL_TOKEN is optional: both ends derive it from
THERMOGRAPH_AUTH_SECRET via HMAC under a domain-separation label, so this needs
no new vault entry. The derivation is pinned to a shared cross-language test
vector asserted on both sides, so drift fails CI instead of 401ing every call.
Fail closed when neither secret is set.
Improvements over the Python: a close intended for RESUME uses 4000 rather than
1000 (Discord invalidates a session closed 1000, so the old default defeated its
own resume); MESSAGE_CREATE runs on a bounded worker pool; and a malformed HELLO
returns an error rather than a clean reconnect, which would otherwise reset
backoff and hot-loop against the gateway.
365 Python tests pass; Go build/vet/test -race clean; shellcheck 0 findings.
2026-07-23 22:49:54 +00:00
|
|
|
|
# The Discord gateway bot and the recurring worker jobs (city warming,
|
|
|
|
|
|
# IndexNow) used to ride this same leader gate in-process; both now live in
|
|
|
|
|
|
# the thermograph-daemon Go binary, which owns the connection/timers and
|
|
|
|
|
|
# calls back over api/internal_routes.py for anything needing data. The
|
|
|
|
|
|
# notifier is the one singleton still running here.
|
Add the Swarm interim stack file, a pinnable TimescaleDB tag, and a Caddy health-gate (#235)
* Split web/worker duties with THERMOGRAPH_ROLE
Background work (the subscription notifier) is welded to the same process
that serves requests, so scaling the web tier to N replicas would also scale
notifier instances unless something restricts it further than leader
election alone.
Add THERMOGRAPH_ROLE (web|worker|all, default all - unchanged single-process
behavior). Every replica runs the same image; ROLE only gates whether a
process is allowed to own the notifier at all, layered on top of the
existing leader election: web replicas never start it even if they'd win
leader election, worker replicas start it if they win. The decision is
pulled into _should_run_notifier() so it's unit-testable without booting the
full app (DB init, places index, neighbor warmer).
Add a minimal /healthz liveness route (no DB/upstream I/O, not under BASE)
so a worker replica - which serves no real traffic - still has something
Swarm can health-check.
* Add the Swarm interim stack file, a pinnable TimescaleDB tag, and a Caddy health-gate
Three changes toward the hop-1 interim cutover, all inert until Track B
stands up the platform:
docker-stack.yml: the Swarm stack file for the interim cutover, distinct
from docker-compose.yml (today's plain-compose deploy, unaffected). Pulls a
pre-built image (IMAGE_TAG) instead of building in place; app/worker publish
no host port (127.0.0.1:8137:8137 has no Swarm equivalent - Swarm's routing
mesh publishes on 0.0.0.0, which would expose the plaintext app un-fronted),
reaching Caddy only over an MTU-lowered overlay network (VXLAN-over-WireGuard
needs a smaller MTU or large payloads silently stall); db is placement-
pinned to a labelled node; app/worker skip inline migrations
(RUN_MIGRATIONS=0) so the runbook's one-shot migrate task is the only thing
that ever runs Alembic; secrets are real Swarm secrets mounted at
/run/secrets, read by the entrypoint shim rather than plain env vars.
TIMESCALEDB_TAG: docker-compose.yml's db image now reads this (default
latest-pg18, today's behavior unchanged), wired through Terraform
(timescaledb_tag, default "latest-pg18") so it can actually be pinned to an
exact minor without hand-editing the host - required before any host of the
stack could replicate with another (a floating tag risks mismatched
extension minors, which blocks a physical replica and risks compressed-
chunk corruption on restore).
Caddy active health-gate: both the Terraform-rendered Caddyfile and the live
deploy/Caddyfile now health-check the app on the same cheap /healthz route
its own Docker HEALTHCHECK uses (now /healthz instead of the SSR homepage,
so it's cheap enough for a tight interval and works identically for a
worker replica, which serves no public traffic at all) - Caddy won't
forward into a container that's still booting or unhealthy.
Verified live: built and booted the real image via docker compose - both
containers report healthy via the new /healthz-based HEALTHCHECK, and GET /
still renders the full SSR homepage unchanged. Both Caddyfiles validated
with the real caddy binary. docker-stack.yml validated with docker compose
config (required-var guards fire with clear messages; secrets correctly
mount at /run/secrets/<name>, matching the entrypoint shim's mapping).
docker-compose.yml validated with and without TIMESCALEDB_TAG set, alongside
the existing openmeteo overlay. terraform validate + fmt clean.
2026-07-21 00:39:48 +00:00
|
|
|
|
if _should_run_notifier():
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
notify.start()
|
2026-07-22 19:07:35 +00:00
|
|
|
|
# Deploy/restart marker: one line correlates a metric shift with a boot.
|
|
|
|
|
|
audit.log_activity("app.start", {"version": app.version, "role": ROLE, "base": BASE,
|
|
|
|
|
|
"notifier": _should_run_notifier(), "pid": os.getpid()})
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
yield
|
2026-07-22 19:07:35 +00:00
|
|
|
|
audit.log_activity("app.stop", {"role": ROLE, "pid": os.getpid()})
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
notify.stop()
|
2026-07-25 04:13:47 +00:00
|
|
|
|
_HEARTBEAT_STOP.set()
|
2026-07-21 20:01:30 +00:00
|
|
|
|
await _frontend_client.aclose()
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app = FastAPI(title="Thermograph", version="0.2.0", lifespan=_lifespan)
|
|
|
|
|
|
|
|
|
|
|
|
# Compress every sizeable response (the 2-year calendar JSON shrinks ~6-8×).
|
|
|
|
|
|
# Applies to API JSON and static assets alike; tiny responses are left alone.
|
2026-07-23 04:41:26 +00:00
|
|
|
|
# compresslevel: GZipMiddleware compresses synchronously ON THE EVENT LOOP, so
|
|
|
|
|
|
# this is CPU work competing with every other async route in the worker while it
|
|
|
|
|
|
# runs — zlib's default level 9 spends a lot of that time chasing the last couple
|
|
|
|
|
|
# percent of ratio for a payload this size. 6 is the standard speed/ratio sweet
|
|
|
|
|
|
# spot (roughly zlib's own recommended default): most of level 9's compression
|
|
|
|
|
|
# but a fraction of the CPU, so a big calendar/day/cell-bundle response doesn't
|
|
|
|
|
|
# stall other requests on this worker for as long. Not worth a custom threaded
|
|
|
|
|
|
# compression middleware for payloads this size — the level drop captures most
|
|
|
|
|
|
# of the win for near-zero complexity.
|
|
|
|
|
|
app.add_middleware(GZipMiddleware, minimum_size=1024, compresslevel=6)
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
|
2026-07-21 20:52:11 +00:00
|
|
|
|
# CORS (repo-split Stage 5): unset (default) means no CORSMiddleware at all --
|
|
|
|
|
|
# today's exact behavior, browsers already allow same-origin fetch with no
|
|
|
|
|
|
# help from us. Comma-separated THERMOGRAPH_CORS_ORIGINS opts in an explicit
|
|
|
|
|
|
# allowlist for a genuinely cross-origin frontend (never "*" -- FastAPI/
|
|
|
|
|
|
# Starlette itself refuses that combined with allow_credentials=True, since a
|
|
|
|
|
|
# credentialed wildcard is exactly the misconfiguration CORS exists to
|
|
|
|
|
|
# prevent). expose_headers=["ETag"] matters as much as allow_origins: without
|
|
|
|
|
|
# it, cache.js's res.headers.get("ETag") silently returns null cross-origin
|
|
|
|
|
|
# (the browser hides every response header except a fixed "simple" allowlist
|
|
|
|
|
|
# by default) and 304 revalidation quietly stops working with no console
|
|
|
|
|
|
# error at all.
|
|
|
|
|
|
_cors_origins = [o.strip() for o in os.environ.get("THERMOGRAPH_CORS_ORIGINS", "").split(",") if o.strip()]
|
|
|
|
|
|
if _cors_origins:
|
|
|
|
|
|
app.add_middleware(
|
|
|
|
|
|
CORSMiddleware,
|
|
|
|
|
|
allow_origins=_cors_origins,
|
|
|
|
|
|
allow_credentials=True,
|
|
|
|
|
|
allow_methods=["*"],
|
|
|
|
|
|
allow_headers=["*"],
|
|
|
|
|
|
expose_headers=["ETag"],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
|
2026-07-22 19:07:35 +00:00
|
|
|
|
# Log a structured line for every unhandled 500. Without an exception handler the
|
|
|
|
|
|
# route exception propagates out past the request middleware (call_next re-raises),
|
|
|
|
|
|
# so a 500 is otherwise invisible in both the access log and metrics — this makes
|
|
|
|
|
|
# it visible and countable (tag="http.error" in the errors stream).
|
|
|
|
|
|
SLOW_REQUEST_MS = 2000.0 # http.slow: log any request slower than this
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.exception_handler(Exception)
|
|
|
|
|
|
async def _log_unhandled_exception(request: Request, exc: Exception) -> Response:
|
|
|
|
|
|
try:
|
|
|
|
|
|
audit.log_event("http.error", {
|
|
|
|
|
|
"phase": "http", "path": request.url.path, "method": request.method,
|
|
|
|
|
|
"cat": metrics.classify_inbound(request.url.path, BASE),
|
|
|
|
|
|
"exc_type": type(exc).__name__, "error": repr(exc)[:500]})
|
|
|
|
|
|
except Exception: # noqa: BLE001 - logging must never mask the original error
|
|
|
|
|
|
pass
|
|
|
|
|
|
return JSONResponse({"detail": "Internal server error."}, status_code=500)
|
|
|
|
|
|
|
|
|
|
|
|
|
Add the Swarm interim stack file, a pinnable TimescaleDB tag, and a Caddy health-gate (#235)
* Split web/worker duties with THERMOGRAPH_ROLE
Background work (the subscription notifier) is welded to the same process
that serves requests, so scaling the web tier to N replicas would also scale
notifier instances unless something restricts it further than leader
election alone.
Add THERMOGRAPH_ROLE (web|worker|all, default all - unchanged single-process
behavior). Every replica runs the same image; ROLE only gates whether a
process is allowed to own the notifier at all, layered on top of the
existing leader election: web replicas never start it even if they'd win
leader election, worker replicas start it if they win. The decision is
pulled into _should_run_notifier() so it's unit-testable without booting the
full app (DB init, places index, neighbor warmer).
Add a minimal /healthz liveness route (no DB/upstream I/O, not under BASE)
so a worker replica - which serves no real traffic - still has something
Swarm can health-check.
* Add the Swarm interim stack file, a pinnable TimescaleDB tag, and a Caddy health-gate
Three changes toward the hop-1 interim cutover, all inert until Track B
stands up the platform:
docker-stack.yml: the Swarm stack file for the interim cutover, distinct
from docker-compose.yml (today's plain-compose deploy, unaffected). Pulls a
pre-built image (IMAGE_TAG) instead of building in place; app/worker publish
no host port (127.0.0.1:8137:8137 has no Swarm equivalent - Swarm's routing
mesh publishes on 0.0.0.0, which would expose the plaintext app un-fronted),
reaching Caddy only over an MTU-lowered overlay network (VXLAN-over-WireGuard
needs a smaller MTU or large payloads silently stall); db is placement-
pinned to a labelled node; app/worker skip inline migrations
(RUN_MIGRATIONS=0) so the runbook's one-shot migrate task is the only thing
that ever runs Alembic; secrets are real Swarm secrets mounted at
/run/secrets, read by the entrypoint shim rather than plain env vars.
TIMESCALEDB_TAG: docker-compose.yml's db image now reads this (default
latest-pg18, today's behavior unchanged), wired through Terraform
(timescaledb_tag, default "latest-pg18") so it can actually be pinned to an
exact minor without hand-editing the host - required before any host of the
stack could replicate with another (a floating tag risks mismatched
extension minors, which blocks a physical replica and risks compressed-
chunk corruption on restore).
Caddy active health-gate: both the Terraform-rendered Caddyfile and the live
deploy/Caddyfile now health-check the app on the same cheap /healthz route
its own Docker HEALTHCHECK uses (now /healthz instead of the SSR homepage,
so it's cheap enough for a tight interval and works identically for a
worker replica, which serves no public traffic at all) - Caddy won't
forward into a container that's still booting or unhealthy.
Verified live: built and booted the real image via docker compose - both
containers report healthy via the new /healthz-based HEALTHCHECK, and GET /
still renders the full SSR homepage unchanged. Both Caddyfiles validated
with the real caddy binary. docker-stack.yml validated with docker compose
config (required-var guards fire with clear messages; secrets correctly
mount at /run/secrets/<name>, matching the entrypoint shim's mapping).
docker-compose.yml validated with and without TIMESCALEDB_TAG set, alongside
the existing openmeteo overlay. terraform validate + fmt clean.
2026-07-21 00:39:48 +00:00
|
|
|
|
@app.get("/healthz")
|
|
|
|
|
|
def healthz():
|
|
|
|
|
|
"""Liveness probe: the process is up and serving. Deliberately does no I/O (no
|
|
|
|
|
|
DB query, no upstream call) so it stays cheap and reliable enough for a tight
|
|
|
|
|
|
Swarm/Docker healthcheck interval — readiness (DB reachable, etc.) is a
|
|
|
|
|
|
separate concern the ingress health-gate covers by hitting a real route. Not
|
|
|
|
|
|
under BASE: a fixed, unauthenticated path a healthcheck can hit without
|
|
|
|
|
|
knowing THERMOGRAPH_BASE, on both the web and worker roles."""
|
|
|
|
|
|
return {"status": "ok", "role": ROLE}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-22 18:41:46 +00:00
|
|
|
|
API_CONTRACT_VERSION = "2" # bump only on a breaking change to any /api/v{N} route or payload shape
|
|
|
|
|
|
MIN_SUPPORTED_FRONTEND = "1" # oldest frontend contract version this backend still serves correctly
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get(f"{BASE}/api/version")
|
|
|
|
|
|
def api_version():
|
|
|
|
|
|
"""Capability/version-negotiation endpoint: lets a split-out frontend detect at
|
|
|
|
|
|
boot whether the backend it's talking to still supports its contract.
|
|
|
|
|
|
Deliberately does no I/O, same posture as /healthz."""
|
|
|
|
|
|
return {
|
|
|
|
|
|
"backend_version": API_CONTRACT_VERSION,
|
|
|
|
|
|
"min_frontend": MIN_SUPPORTED_FRONTEND,
|
|
|
|
|
|
"payload_ver": payloads.PAYLOAD_VER,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
def _client_ip(request) -> "str | None":
|
|
|
|
|
|
"""The real client IP: the left-most X-Forwarded-For hop when a proxy fronts us
|
2026-07-24 19:28:47 +00:00
|
|
|
|
(Caddy on prod sets it), otherwise the direct peer address (LAN dev). Full
|
|
|
|
|
|
precision — the rate limiter (metrics._rate_ok) needs the exact address, since
|
|
|
|
|
|
a truncated key would let one abuser exhaust a whole NAT'd office's quota.
|
|
|
|
|
|
Truncate at the point of persistence instead (see _loggable_ip)."""
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
xff = request.headers.get("x-forwarded-for")
|
|
|
|
|
|
if xff:
|
|
|
|
|
|
return xff.split(",")[0].strip()
|
|
|
|
|
|
return request.client.host if request.client else None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-24 19:28:47 +00:00
|
|
|
|
def _loggable_ip(ip: "str | None") -> "str | None":
|
|
|
|
|
|
"""Coarsen a client IP before it's written to the access log: /24 for IPv4,
|
|
|
|
|
|
/48 for IPv6. Keeps rough geo/abuse signal without keeping a full, joinable
|
|
|
|
|
|
address sitting in structured logs for the log's 30-day retention -- the
|
|
|
|
|
|
public privacy page promises IPs aren't logged beyond normal request handling,
|
|
|
|
|
|
and a raw address shipped to Loki was a live gap against that. Never the
|
|
|
|
|
|
input to the rate limiter (_client_ip's callers pass the untruncated value
|
|
|
|
|
|
there) -- this is only what gets persisted."""
|
|
|
|
|
|
if not ip:
|
|
|
|
|
|
return ip
|
|
|
|
|
|
try:
|
|
|
|
|
|
addr = ipaddress.ip_address(ip)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
return ip # not a parseable address (e.g. a test/placeholder value) -- pass through
|
|
|
|
|
|
prefix = 24 if addr.version == 4 else 48
|
|
|
|
|
|
return str(ipaddress.ip_network(f"{addr}/{prefix}", strict=False).network_address)
|
|
|
|
|
|
|
|
|
|
|
|
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
@app.middleware("http")
|
|
|
|
|
|
async def revalidate_static(request, call_next):
|
|
|
|
|
|
"""Serve the frontend with no-cache (NOT no-store): browsers may keep a copy
|
|
|
|
|
|
but must revalidate it on every use. Starlette's FileResponse/StaticFiles
|
|
|
|
|
|
already emit ETag + Last-Modified, so an unchanged asset costs one conditional
|
|
|
|
|
|
request answered with an empty 304 instead of a full re-download — page-to-page
|
|
|
|
|
|
navigation stops re-transferring the JS/CSS/HTML while still picking up every
|
|
|
|
|
|
deploy immediately (no "my change isn't showing" bugs)."""
|
2026-07-22 19:07:35 +00:00
|
|
|
|
t0 = time.perf_counter()
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
response = await call_next(request)
|
2026-07-22 19:07:35 +00:00
|
|
|
|
dur_ms = (time.perf_counter() - t0) * 1000.0
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
path = request.url.path
|
|
|
|
|
|
try:
|
|
|
|
|
|
cat = metrics.classify_inbound(path, BASE)
|
2026-07-22 19:07:15 +00:00
|
|
|
|
# Both are blocking (sync DB write, sync file write) — run off the event
|
|
|
|
|
|
# loop so one request's instrumentation can't stall every other request
|
|
|
|
|
|
# this worker is serving concurrently.
|
|
|
|
|
|
await run_in_threadpool(metrics.record_inbound, cat, response.status_code)
|
2026-07-24 19:28:47 +00:00
|
|
|
|
# Retain per-request client IPs for later analysis; skip static assets, the
|
|
|
|
|
|
# dashboard's own metrics polling, the liveness probe (health -- by far the
|
|
|
|
|
|
# highest-volume single path, and never real traffic), and the internal
|
|
|
|
|
|
# SSR->API hop (internal -- frontend_ssr's own server-to-server calls, not
|
|
|
|
|
|
# a page view) to keep the log to real, meaningful traffic. The IP itself is
|
|
|
|
|
|
# truncated (see _loggable_ip) -- coarse geo/abuse signal, not a full address.
|
|
|
|
|
|
if cat not in ("static", "metrics", "event", "health", "internal"):
|
2026-07-22 19:07:15 +00:00
|
|
|
|
await run_in_threadpool(audit.log_access, {
|
2026-07-24 19:28:47 +00:00
|
|
|
|
"ip": _loggable_ip(_client_ip(request)), "method": request.method,
|
2026-07-22 19:07:15 +00:00
|
|
|
|
"path": path, "status": response.status_code, "cat": cat})
|
2026-07-22 19:07:35 +00:00
|
|
|
|
# Threshold-gated slow-request line: RunAudit only times the 7 graded
|
|
|
|
|
|
# endpoints, so this is the only latency signal for auth/account/content
|
|
|
|
|
|
# routes. Kept rare (>2s) so it never becomes per-request hot-path noise.
|
|
|
|
|
|
# Also a blocking file write, so keep it off the event loop.
|
|
|
|
|
|
if dur_ms > SLOW_REQUEST_MS and cat != "static":
|
|
|
|
|
|
await run_in_threadpool(audit.log_activity, "http.slow", {
|
|
|
|
|
|
"path": path, "method": request.method, "cat": cat,
|
|
|
|
|
|
"status": response.status_code, "duration_ms": round(dur_ms, 1)})
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
except Exception: # noqa: BLE001 - never let instrumentation break a response
|
|
|
|
|
|
pass
|
|
|
|
|
|
pages = (BASE, f"{BASE}/", f"{BASE}/calendar", f"{BASE}/day", f"{BASE}/score",
|
|
|
|
|
|
f"{BASE}/compare", f"{BASE}/legend", f"{BASE}/alerts", f"{BASE}/privacy")
|
|
|
|
|
|
if path.endswith((".js", ".css", ".html")) or path in pages:
|
|
|
|
|
|
response.headers["Cache-Control"] = "no-cache"
|
|
|
|
|
|
return response
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- derived-store plumbing --------------------------------------------------
|
|
|
|
|
|
# Every graded payload is cached in SQLite under (kind, cell, key) and validated
|
|
|
|
|
|
# by a token that encodes what it was computed from (see store.py). The freshness
|
|
|
|
|
|
# drivers stay where they were — climate.get_history tops up the archive tail
|
|
|
|
|
|
# hourly and get_recent_forecast refetches hourly — and the tokens are derived
|
|
|
|
|
|
# from what those return, so a cached payload expires exactly when its inputs
|
|
|
|
|
|
# change and never before. The same tokens double as ETags: a client sending
|
|
|
|
|
|
# If-None-Match gets an empty 304 without the payload even being loaded.
|
2026-07-21 16:09:35 +00:00
|
|
|
|
# The payloads themselves are assembled in api/payloads.py, shared with the
|
|
|
|
|
|
# offline migrate script.
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
|
|
|
|
|
|
def _etag_for(kind: str, cell_id: str, key: str, token: str) -> str:
|
|
|
|
|
|
"""Deterministic weak ETag from a payload's identity + validity token. Weak
|
|
|
|
|
|
because the same content may be served under different encodings (gzip)."""
|
|
|
|
|
|
h = hashlib.sha1(f"{kind}:{cell_id}:{key}:{token}".encode()).hexdigest()[:20]
|
|
|
|
|
|
return f'W/"{h}"'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _not_modified(request: Request, etag: str) -> bool:
|
|
|
|
|
|
"""Does the client already hold this exact payload? Answerable from the token
|
|
|
|
|
|
alone — no payload load needed."""
|
|
|
|
|
|
inm = request.headers.get("if-none-match")
|
|
|
|
|
|
if not inm:
|
|
|
|
|
|
return False
|
|
|
|
|
|
tags = {t.strip() for t in inm.split(",")}
|
|
|
|
|
|
return "*" in tags or etag in tags or etag.removeprefix("W/") in tags
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _encode(payload: dict) -> bytes:
|
|
|
|
|
|
"""Compact JSON bytes, matching store.put_payload's encoding (allow_nan=False
|
|
|
|
|
|
mirrors Starlette — a NaN from grading should fail loudly, not reach a client)."""
|
|
|
|
|
|
return json.dumps(payload, default=str, allow_nan=False, separators=(",", ":")).encode()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _json_response(body: bytes, etag: str | None = None) -> Response:
|
|
|
|
|
|
headers = {"ETag": etag} if etag else {}
|
|
|
|
|
|
return Response(content=body, media_type="application/json", headers=headers)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fetch_history(run, cell, recent_too=False, recent_phase="recent"):
|
|
|
|
|
|
"""The shared fetch preamble for every data route: the archive record (and
|
|
|
|
|
|
optionally the hourly recent/forecast bundle) with upstream failures mapped
|
|
|
|
|
|
to clean HTTP errors and an empty record to a 404."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
with run.phase("history"):
|
|
|
|
|
|
history, cache_meta = climate.get_history(cell)
|
|
|
|
|
|
recent = None
|
|
|
|
|
|
if recent_too:
|
|
|
|
|
|
with run.phase(recent_phase):
|
|
|
|
|
|
recent = climate.get_recent_forecast(cell)
|
|
|
|
|
|
except Exception as e: # noqa: BLE001
|
|
|
|
|
|
raise _weather_fetch_error(e)
|
|
|
|
|
|
if history.is_empty():
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="No historical data for this cell.")
|
|
|
|
|
|
return history, cache_meta, recent
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _cached_response(request, run, kind, cell, key, token, build, cache_placeless=True):
|
|
|
|
|
|
"""The shared derived-store flow behind every per-view endpoint:
|
|
|
|
|
|
If-None-Match -> empty 304; a token-valid store row -> replay its exact
|
|
|
|
|
|
bytes; otherwise resolve the place label, build the payload, persist, serve.
|
|
|
|
|
|
|
|
|
|
|
|
``build(place) -> payload`` does any endpoint-specific audit tagging itself.
|
|
|
|
|
|
``cache_placeless=False`` skips persisting a payload whose place label
|
|
|
|
|
|
failed to resolve (a transient reverse-geocode miss) — otherwise the
|
|
|
|
|
|
bare-coordinates fallback would stick for the life of the token."""
|
|
|
|
|
|
cid = cell["id"]
|
|
|
|
|
|
etag = _etag_for(kind, cid, key, token)
|
|
|
|
|
|
if _not_modified(request, etag):
|
|
|
|
|
|
run.set(run_type="cache", not_modified=True)
|
|
|
|
|
|
return Response(status_code=304, headers={"ETag": etag})
|
|
|
|
|
|
body = store.get_payload(kind, cid, key, token)
|
|
|
|
|
|
if body is not None:
|
|
|
|
|
|
run.set(run_type="cache", history_source="store")
|
|
|
|
|
|
return _json_response(body, etag)
|
|
|
|
|
|
with run.phase("reverse_geocode"):
|
|
|
|
|
|
place = climate.reverse_geocode(cell["center_lat"], cell["center_lon"])
|
|
|
|
|
|
payload = build(place)
|
|
|
|
|
|
return _json_response(
|
|
|
|
|
|
store.put_payload(kind, cid, key, token, payload,
|
|
|
|
|
|
cache=cache_placeless or place is not None), etag)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- endpoints ----------------------------------------------------------------
|
|
|
|
|
|
|
2026-07-23 14:34:51 +00:00
|
|
|
|
_GEOCODE_LIMIT = 5
|
|
|
|
|
|
|
|
|
|
|
|
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
def api_geocode(q: str = Query(..., min_length=1)):
|
2026-07-23 14:34:51 +00:00
|
|
|
|
"""Forward place search. The local GeoNames index answers first (no network);
|
|
|
|
|
|
only a genuine miss — or a query the index can't hold (neighbourhoods,
|
|
|
|
|
|
postcodes, tiny villages, native-language names) — falls back to Nominatim.
|
|
|
|
|
|
`search` returns None while the index is still loading, which also falls
|
|
|
|
|
|
through to the network so search keeps working during warmup."""
|
|
|
|
|
|
local = places.search(q, _GEOCODE_LIMIT)
|
|
|
|
|
|
if local:
|
|
|
|
|
|
# Strip the internal prefix/fuzzy tag so the wire shape matches the
|
|
|
|
|
|
# Nominatim branch (and the former Open-Meteo contract) exactly.
|
|
|
|
|
|
return {"results": [{k: v for k, v in r.items() if k != "match"} for r in local]}
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
try:
|
2026-07-23 14:34:51 +00:00
|
|
|
|
return {"results": climate.geocode_nominatim(q, _GEOCODE_LIMIT)}
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
except Exception as e: # noqa: BLE001 - surface upstream failures to the client
|
|
|
|
|
|
raise HTTPException(status_code=502, detail=f"geocoding failed: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_SUGGEST_LIMIT = 5
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 14:34:51 +00:00
|
|
|
|
def _no_upstream(q: str) -> tuple:
|
|
|
|
|
|
"""Autocomplete is served purely from the local GeoNames index — no per-keystroke
|
|
|
|
|
|
call to any external geocoder. Typo-correction still runs against the local
|
|
|
|
|
|
index, so respellings ("pest seattle" → "west seattle") keep working offline."""
|
|
|
|
|
|
return ()
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def api_suggest(q: str = Query(..., min_length=1)):
|
|
|
|
|
|
"""Type-ahead location suggestions: the top 5 places for a (possibly
|
|
|
|
|
|
typo'd) query prefix; `corrected` reports the respelling that produced the
|
|
|
|
|
|
results ("pest seattle" → "west seattle"), or null. The ranking/typo policy
|
2026-07-23 14:34:51 +00:00
|
|
|
|
lives in places.suggest, now driven entirely by the local index."""
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
try:
|
2026-07-23 14:34:51 +00:00
|
|
|
|
results, corrected = places.suggest(q, _no_upstream, _SUGGEST_LIMIT)
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
except Exception as e: # noqa: BLE001 - nothing at all to serve
|
|
|
|
|
|
raise HTTPException(status_code=502, detail=f"geocoding failed: {e}")
|
|
|
|
|
|
return {"results": results, "corrected": corrected}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def api_place(
|
|
|
|
|
|
lat: float = Query(..., ge=-90, le=90),
|
|
|
|
|
|
lon: float = Query(..., ge=-180, le=180),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Best-effort neighbourhood/city label for a point — the same string the view
|
|
|
|
|
|
endpoints expose as ``place`` (snapped to the grid cell, reverse-geocoded and
|
|
|
|
|
|
cached). Resolved on its own so the compare page can show a location's name as
|
|
|
|
|
|
soon as it's added, before its full series loads."""
|
|
|
|
|
|
cell = grid.snap(lat, lon)
|
|
|
|
|
|
with audit.RunAudit(endpoint="place", lat=round(lat, 4), lon=round(lon, 4),
|
|
|
|
|
|
cell_id=cell["id"]) as run:
|
|
|
|
|
|
with run.phase("reverse_geocode"):
|
|
|
|
|
|
place = climate.reverse_geocode(cell["center_lat"], cell["center_lon"])
|
|
|
|
|
|
return {"place": place,
|
|
|
|
|
|
"cell": {"center_lat": cell["center_lat"], "center_lon": cell["center_lon"]}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def api_grade(
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
lat: float = Query(..., ge=-90, le=90),
|
|
|
|
|
|
lon: float = Query(..., ge=-180, le=180),
|
|
|
|
|
|
date: str | None = Query(None, description="target date YYYY-MM-DD (default today)"),
|
|
|
|
|
|
days: int = Query(14, ge=1, le=60, description="days of history to grade before the target"),
|
|
|
|
|
|
after: int = Query(14, ge=0, le=14,
|
|
|
|
|
|
description="days to grade after the target (observed, or forecast when future; "
|
|
|
|
|
|
"the forecast reaches ~7 days out so future days cap there)"),
|
|
|
|
|
|
):
|
2026-07-24 23:13:36 +00:00
|
|
|
|
target = _parse_target_date(date, datetime.date.today())
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
cell = grid.snap(lat, lon)
|
|
|
|
|
|
|
|
|
|
|
|
with audit.RunAudit(
|
|
|
|
|
|
endpoint="grade",
|
|
|
|
|
|
lat=round(lat, 4),
|
|
|
|
|
|
lon=round(lon, 4),
|
|
|
|
|
|
target_date=target.isoformat(),
|
|
|
|
|
|
recent_days=days,
|
|
|
|
|
|
cell_id=cell["id"],
|
|
|
|
|
|
) as run:
|
|
|
|
|
|
history, cache_meta, recent = _fetch_history(run, cell, recent_too=True)
|
|
|
|
|
|
return _cached_response(
|
|
|
|
|
|
request, run, "grade", cell,
|
2026-07-21 16:09:35 +00:00
|
|
|
|
payloads.grade_key(target, days, after), payloads.recent_token(history, cell["id"]),
|
|
|
|
|
|
lambda place: payloads.build_grade(cell, target, days, history, recent,
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
cache_meta, place, run, after=after))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def api_calendar(
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
lat: float = Query(..., ge=-90, le=90),
|
|
|
|
|
|
lon: float = Query(..., ge=-180, le=180),
|
|
|
|
|
|
start: str | None = Query(None, description="first date YYYY-MM-DD (overrides months)"),
|
|
|
|
|
|
end: str | None = Query(None, description="last date YYYY-MM-DD (default = latest available)"),
|
|
|
|
|
|
months: int = Query(24, ge=1, le=24, description="months back to grade when no start given"),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Grade every day over a date range (default: last 2 years) for the calendar view.
|
|
|
|
|
|
|
|
|
|
|
|
A custom [start, end] range is supported and capped at ~2 years per request;
|
|
|
|
|
|
the frontend splits longer spans into successive 2-year chunks. Grades against
|
|
|
|
|
|
the already-cached history record (which reaches to ~6 days ago), so no extra
|
|
|
|
|
|
fetch is needed. The graded payload is cached in the derived store keyed by
|
|
|
|
|
|
the clamped span and validated by the record's end date, so a repeat span is
|
|
|
|
|
|
a database read — across restarts — until new archive days arrive. Returns a
|
|
|
|
|
|
compact per-day shape (see grading.grade_range). New in API v2.
|
|
|
|
|
|
"""
|
|
|
|
|
|
cell = grid.snap(lat, lon)
|
|
|
|
|
|
|
|
|
|
|
|
with audit.RunAudit(
|
|
|
|
|
|
endpoint="calendar",
|
|
|
|
|
|
lat=round(lat, 4),
|
|
|
|
|
|
lon=round(lon, 4),
|
|
|
|
|
|
recent_days=months * 31, # approximate span graded
|
|
|
|
|
|
cell_id=cell["id"],
|
|
|
|
|
|
) as run:
|
|
|
|
|
|
history, cache_meta, _ = _fetch_history(run, cell)
|
2026-07-21 16:09:35 +00:00
|
|
|
|
start_ts, end_ts = payloads.cal_span(history, start, end, months)
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
|
|
|
|
|
|
def build(place):
|
2026-07-21 16:09:35 +00:00
|
|
|
|
payload = payloads.build_calendar(cell, history, start_ts, end_ts, months, place, run)
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
full = not cache_meta.get("cached", False)
|
|
|
|
|
|
run.set(run_type="full" if full else "partial",
|
|
|
|
|
|
history_source="fetch" if full else "cache")
|
|
|
|
|
|
return payload
|
|
|
|
|
|
|
|
|
|
|
|
# Compare loads several cells at once, so a transient reverse-geocode miss
|
|
|
|
|
|
# is most likely here; cache_placeless=False keeps that miss un-persisted.
|
|
|
|
|
|
return _cached_response(request, run, "calendar", cell,
|
2026-07-21 16:09:35 +00:00
|
|
|
|
payloads.calendar_key(start_ts, end_ts, months),
|
|
|
|
|
|
payloads.history_token(history), build,
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
cache_placeless=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def api_day(
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
lat: float = Query(..., ge=-90, le=90),
|
|
|
|
|
|
lon: float = Query(..., ge=-180, le=180),
|
|
|
|
|
|
date: str | None = Query(None, description="target date YYYY-MM-DD (default = latest available)"),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Full percentile breakdown for a single day: the value at every tier boundary
|
|
|
|
|
|
in that day-of-year's ±7-day window, plus where the observed values land.
|
|
|
|
|
|
|
|
|
|
|
|
Powers the single-day detail page. Grades against the cached history record;
|
|
|
|
|
|
for a date newer than the cache it pulls the recent window for the observation
|
|
|
|
|
|
(those payloads expire hourly — the recent bundle's own cadence — while fully
|
|
|
|
|
|
archived days stay valid until the record itself advances). New in API v2.
|
|
|
|
|
|
"""
|
|
|
|
|
|
cell = grid.snap(lat, lon)
|
|
|
|
|
|
|
|
|
|
|
|
with audit.RunAudit(
|
|
|
|
|
|
endpoint="day",
|
|
|
|
|
|
lat=round(lat, 4),
|
|
|
|
|
|
lon=round(lon, 4),
|
|
|
|
|
|
cell_id=cell["id"],
|
|
|
|
|
|
) as run:
|
|
|
|
|
|
history, cache_meta, _ = _fetch_history(run, cell)
|
|
|
|
|
|
last = history["date"].max()
|
2026-07-24 23:13:36 +00:00
|
|
|
|
target = _parse_target_date(date, last)
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
run.set(target_date=target.isoformat())
|
|
|
|
|
|
|
|
|
|
|
|
def build(place):
|
2026-07-21 16:09:35 +00:00
|
|
|
|
payload = payloads.build_day(cell, history, target, place, run)
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
full = not cache_meta.get("cached", False)
|
|
|
|
|
|
run.set(run_type="full" if full else "partial",
|
|
|
|
|
|
history_source="fetch" if full else "cache")
|
|
|
|
|
|
return payload
|
|
|
|
|
|
|
|
|
|
|
|
return _cached_response(request, run, "day", cell,
|
2026-07-21 16:09:35 +00:00
|
|
|
|
payloads.day_key(target), payloads.day_token(history, target),
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
build)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def api_score(
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
lat: float = Query(..., ge=-90, le=90),
|
|
|
|
|
|
lon: float = Query(..., ge=-180, le=180),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Climate-drift score for a location: how far the last 6 years have moved
|
|
|
|
|
|
from the full 45-year baseline, per metric, percentile category and season,
|
|
|
|
|
|
rolled into per-metric and overall scores.
|
|
|
|
|
|
|
|
|
|
|
|
Derived purely from the archive record, so it is cached until the record's
|
|
|
|
|
|
tail advances (the hourly top-up); the scoring-math version is baked into the
|
|
|
|
|
|
cache key so a math change invalidates only score rows. New in API v2.
|
|
|
|
|
|
"""
|
|
|
|
|
|
cell = grid.snap(lat, lon)
|
|
|
|
|
|
|
|
|
|
|
|
with audit.RunAudit(endpoint="score", lat=round(lat, 4), lon=round(lon, 4),
|
|
|
|
|
|
cell_id=cell["id"]) as run:
|
|
|
|
|
|
history, cache_meta, _ = _fetch_history(run, cell)
|
|
|
|
|
|
|
|
|
|
|
|
def build(place):
|
2026-07-21 16:09:35 +00:00
|
|
|
|
payload = payloads.build_score(cell, history, place, run)
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
full = not cache_meta.get("cached", False)
|
|
|
|
|
|
run.set(run_type="full" if full else "partial",
|
|
|
|
|
|
history_source="fetch" if full else "cache")
|
|
|
|
|
|
return payload
|
|
|
|
|
|
|
|
|
|
|
|
return _cached_response(request, run, "score", cell,
|
2026-07-21 16:09:35 +00:00
|
|
|
|
payloads.score_key(), payloads.history_token(history), build)
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def api_forecast(
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
lat: float = Query(..., ge=-90, le=90),
|
|
|
|
|
|
lon: float = Query(..., ge=-180, le=180),
|
|
|
|
|
|
days: int = Query(7, ge=1, le=14, description="how many forecast days ahead to grade"),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Grade the next `days` of forecast weather against local climatology.
|
|
|
|
|
|
|
|
|
|
|
|
Same shape as /grade (so the frontend renders it identically), but `recent`
|
|
|
|
|
|
holds the forward forecast — furthest-out day first. The forecast is fetched
|
|
|
|
|
|
fresh and cached only ~1 hour (see climate.get_recent_forecast) to track
|
|
|
|
|
|
updates; the graded payload's validity is tied to that fetch stamp, so it
|
|
|
|
|
|
expires exactly when a new forecast lands. New in API v2.
|
|
|
|
|
|
"""
|
|
|
|
|
|
cell = grid.snap(lat, lon)
|
|
|
|
|
|
|
|
|
|
|
|
with audit.RunAudit(endpoint="forecast", lat=round(lat, 4), lon=round(lon, 4),
|
|
|
|
|
|
recent_days=days, cell_id=cell["id"]) as run:
|
|
|
|
|
|
history, _, fc = _fetch_history(run, cell, recent_too=True, recent_phase="forecast")
|
|
|
|
|
|
today = datetime.date.today()
|
|
|
|
|
|
|
|
|
|
|
|
def build(place):
|
2026-07-21 16:09:35 +00:00
|
|
|
|
payload = payloads.build_forecast(cell, days, history, fc, today, place, run)
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
run.set(run_type="partial")
|
|
|
|
|
|
return payload
|
|
|
|
|
|
|
|
|
|
|
|
return _cached_response(request, run, "forecast", cell,
|
2026-07-21 16:09:35 +00:00
|
|
|
|
payloads.forecast_key(today, days),
|
|
|
|
|
|
payloads.recent_token(history, cell["id"]), build)
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def api_cell(
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
lat: float = Query(..., ge=-90, le=90),
|
|
|
|
|
|
lon: float = Query(..., ge=-180, le=180),
|
|
|
|
|
|
prefetch: int = Query(0, ge=0, le=1,
|
|
|
|
|
|
description="1 = warm-only: never fetch weather upstream; 204 for a cold cell"),
|
|
|
|
|
|
neighbors: int = Query(0, ge=0, le=1,
|
|
|
|
|
|
description="1 = also warm the 8 surrounding cells in the background "
|
|
|
|
|
|
"(warm-only: cold neighbors are skipped, no upstream quota)"),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""One bundle carrying every view's payload for a cell, so the frontend warms
|
|
|
|
|
|
all views with a single request instead of four.
|
|
|
|
|
|
|
|
|
|
|
|
Each slice is the EXACT payload its per-view endpoint returns — built by the
|
|
|
|
|
|
same builders and cached under the same derived-store keys/tokens — paired
|
|
|
|
|
|
with the etag that endpoint would emit. The client seeds its per-view cache
|
|
|
|
|
|
from the slices and later revalidates each view individually with
|
|
|
|
|
|
If-None-Match, so the bundle and the per-view endpoints stay one cache.
|
|
|
|
|
|
|
|
|
|
|
|
prefetch=1 is the neighbor-warming mode with a hard guarantee: it never
|
|
|
|
|
|
spends weather-API quota. A cell with no cached archive answers 204 (no
|
|
|
|
|
|
body), and only the history-derived slices (calendar + latest-day detail)
|
|
|
|
|
|
are built — grading recent/forecast days needs the hourly upstream bundle.
|
|
|
|
|
|
Reverse geocoding makes at most one Nominatim call for a never-labeled cell;
|
|
|
|
|
|
the client staggers neighbor prefetches to respect that service. New in API v2.
|
|
|
|
|
|
"""
|
|
|
|
|
|
cell = grid.snap(lat, lon)
|
|
|
|
|
|
today = datetime.date.today()
|
|
|
|
|
|
|
|
|
|
|
|
with audit.RunAudit(endpoint="cell", lat=round(lat, 4), lon=round(lon, 4),
|
|
|
|
|
|
cell_id=cell["id"], prefetch=bool(prefetch)) as run:
|
|
|
|
|
|
recent = None
|
|
|
|
|
|
if prefetch:
|
|
|
|
|
|
history = climate.load_cached_history(cell)
|
|
|
|
|
|
if history is None or history.is_empty():
|
|
|
|
|
|
run.set(run_type="cold-skip")
|
|
|
|
|
|
return Response(status_code=204)
|
|
|
|
|
|
cache_meta = {"cached": True}
|
|
|
|
|
|
else:
|
|
|
|
|
|
history, cache_meta, recent = _fetch_history(run, cell, recent_too=True)
|
|
|
|
|
|
|
|
|
|
|
|
cid = cell["id"]
|
|
|
|
|
|
last = history["date"].max()
|
|
|
|
|
|
|
|
|
|
|
|
if neighbors:
|
|
|
|
|
|
_enqueue_neighbor_warming(cell)
|
|
|
|
|
|
|
|
|
|
|
|
with run.phase("reverse_geocode"):
|
|
|
|
|
|
place = climate.reverse_geocode(cell["center_lat"], cell["center_lon"])
|
|
|
|
|
|
|
|
|
|
|
|
def slice_for(kind: str, key: str, token: str, build) -> dict:
|
|
|
|
|
|
"""The endpoint's derived row (or build + store it), paired with the
|
|
|
|
|
|
etag that endpoint would emit for the same request."""
|
|
|
|
|
|
etag = _etag_for(kind, cid, key, token)
|
|
|
|
|
|
data = store.get_json(kind, cid, key, token)
|
|
|
|
|
|
if data is None:
|
|
|
|
|
|
data = build()
|
|
|
|
|
|
store.put_payload(kind, cid, key, token, data)
|
|
|
|
|
|
return {"etag": etag, "data": data}
|
|
|
|
|
|
|
|
|
|
|
|
slices = {}
|
2026-07-21 16:09:35 +00:00
|
|
|
|
hist_token = payloads.history_token(history)
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
# Calendar: the default last-24-months span — what the calendar view (and
|
|
|
|
|
|
# the cross-view prefetch) requests first.
|
2026-07-21 16:09:35 +00:00
|
|
|
|
start_ts, end_ts = payloads.cal_span(history, None, None, 24)
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
slices["calendar"] = slice_for(
|
2026-07-21 16:09:35 +00:00
|
|
|
|
"calendar", payloads.calendar_key(start_ts, end_ts, 24), hist_token,
|
|
|
|
|
|
lambda: payloads.build_calendar(cell, history, start_ts, end_ts, 24, place, run))
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
|
|
|
|
|
|
if prefetch:
|
|
|
|
|
|
# Latest archived day: its observation comes from history alone, so
|
|
|
|
|
|
# it's buildable without the (skipped) recent bundle.
|
|
|
|
|
|
slices["day"] = slice_for(
|
2026-07-21 16:09:35 +00:00
|
|
|
|
"day", payloads.day_key(last), hist_token,
|
|
|
|
|
|
lambda: payloads.build_day(cell, history, last, place, run))
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
else:
|
2026-07-21 16:09:35 +00:00
|
|
|
|
rf_token = payloads.recent_token(history, cid)
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
slices["grade"] = slice_for(
|
2026-07-21 16:09:35 +00:00
|
|
|
|
"grade", payloads.grade_key(today, 14, 14), rf_token,
|
|
|
|
|
|
lambda: payloads.build_grade(cell, today, 14, history, recent, cache_meta, place, run, after=14))
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
slices["forecast"] = slice_for(
|
2026-07-21 16:09:35 +00:00
|
|
|
|
"forecast", payloads.forecast_key(today, 7), rf_token,
|
|
|
|
|
|
lambda: payloads.build_forecast(cell, 7, history, recent, today, place, run))
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
slices["day"] = slice_for(
|
2026-07-21 16:09:35 +00:00
|
|
|
|
"day", payloads.day_key(today), payloads.day_token(history, today),
|
|
|
|
|
|
lambda: payloads.build_day(cell, history, today, place, run, recent=recent))
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
|
|
|
|
|
|
# The bundle's identity is the combination of its slices' identities.
|
|
|
|
|
|
etag = _etag_for("cell", cid, "bundle" + (":p" if prefetch else ""),
|
|
|
|
|
|
"|".join(s["etag"] for s in slices.values()))
|
|
|
|
|
|
if _not_modified(request, etag):
|
|
|
|
|
|
run.set(run_type="cache", not_modified=True)
|
|
|
|
|
|
return Response(status_code=304, headers={"ETag": etag})
|
|
|
|
|
|
run.set(run_type="bundle", slices=sorted(slices))
|
|
|
|
|
|
payload = {
|
|
|
|
|
|
"api_version": "v2",
|
|
|
|
|
|
"cell": cell,
|
|
|
|
|
|
"place": place,
|
|
|
|
|
|
"today": today.isoformat(),
|
|
|
|
|
|
"slices": slices,
|
|
|
|
|
|
}
|
|
|
|
|
|
# Not stored as its own derived row — the slices already are.
|
|
|
|
|
|
return _json_response(_encode(payload), etag)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- ops metrics (gated) ---------------------------------------------------
|
|
|
|
|
|
def _metrics_allowed(request: Request) -> bool:
|
|
|
|
|
|
"""Guard the ops metrics: a valid token allows from anywhere (for SSH tunnels);
|
|
|
|
|
|
with no token it's direct-loopback-only, and any forwarded header (i.e. proxied
|
|
|
|
|
|
via Caddy) is refused — so prod ops data never leaks through the public domain."""
|
|
|
|
|
|
token = os.environ.get("THERMOGRAPH_METRICS_TOKEN", "").strip()
|
|
|
|
|
|
if token:
|
|
|
|
|
|
supplied = request.headers.get("x-metrics-token") or request.query_params.get("token") or ""
|
|
|
|
|
|
if hmac.compare_digest(supplied, token):
|
|
|
|
|
|
return True
|
|
|
|
|
|
client = request.client.host if request.client else None
|
|
|
|
|
|
forwarded = any(h in request.headers for h in
|
|
|
|
|
|
("x-forwarded-for", "x-forwarded-host", "x-forwarded-proto"))
|
|
|
|
|
|
return client in ("127.0.0.1", "::1") and not forwarded
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def api_metrics(request: Request):
|
|
|
|
|
|
"""Live in-process traffic counters + worker facts for the ops dashboard."""
|
|
|
|
|
|
if not _metrics_allowed(request):
|
|
|
|
|
|
raise HTTPException(status_code=404) # 404, not 403 — don't reveal the route
|
|
|
|
|
|
snap = metrics.snapshot()
|
|
|
|
|
|
snap["warm_queue_depth"] = _warm_queue.qsize()
|
|
|
|
|
|
snap["warm_seen"] = len(_WARM_SEEN)
|
|
|
|
|
|
snap["threads"] = threading.active_count()
|
|
|
|
|
|
snap["thread_names"] = sorted(t.name for t in threading.enumerate())
|
|
|
|
|
|
return snap
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def api_event(request: Request) -> Response:
|
|
|
|
|
|
"""Record one product event (a tap on "use my location", a digest signup, …).
|
|
|
|
|
|
|
|
|
|
|
|
Deliberately minimal: the body carries only an allowlisted event name. The
|
|
|
|
|
|
referrer is read from this request's own Referer header — a client-supplied
|
|
|
|
|
|
one would be forgeable and buys nothing — and is reduced to a bare domain.
|
|
|
|
|
|
No cookies, no per-visitor id, no full URLs.
|
|
|
|
|
|
|
|
|
|
|
|
Always answers 204, whether the event was counted, rejected by the allowlist,
|
|
|
|
|
|
or dropped by the rate limiter, so probing the endpoint reveals nothing. That
|
|
|
|
|
|
also suits navigator.sendBeacon, which ignores the response.
|
|
|
|
|
|
"""
|
|
|
|
|
|
name = ""
|
|
|
|
|
|
try:
|
|
|
|
|
|
body = await request.json()
|
|
|
|
|
|
if isinstance(body, dict):
|
|
|
|
|
|
name = str(body.get("event") or "")
|
|
|
|
|
|
except Exception: # noqa: BLE001 - a junk body is just a no-op event
|
|
|
|
|
|
pass
|
|
|
|
|
|
if name:
|
2026-07-22 19:07:15 +00:00
|
|
|
|
await run_in_threadpool(
|
|
|
|
|
|
metrics.record_event,
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
name,
|
|
|
|
|
|
referer=request.headers.get("referer"),
|
|
|
|
|
|
host=request.headers.get("host"),
|
|
|
|
|
|
ip=_client_ip(request),
|
|
|
|
|
|
)
|
|
|
|
|
|
return Response(status_code=204)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- API versioning --------------------------------------------------------
|
|
|
|
|
|
# Non-backward-compatible additions land in a new version; every version stays
|
|
|
|
|
|
# mounted and served simultaneously. v1 is the original contract (grade/geocode);
|
|
|
|
|
|
# v2 adds the calendar. The unversioned /api/* paths are kept as aliases of v1 so
|
|
|
|
|
|
# existing clients keep working.
|
|
|
|
|
|
v1 = APIRouter(tags=["v1"])
|
|
|
|
|
|
v1.add_api_route("/geocode", api_geocode, methods=["GET"])
|
|
|
|
|
|
v1.add_api_route("/grade", api_grade, methods=["GET"])
|
|
|
|
|
|
|
|
|
|
|
|
v2 = APIRouter(tags=["v2"])
|
|
|
|
|
|
v2.add_api_route("/geocode", api_geocode, methods=["GET"])
|
|
|
|
|
|
v2.add_api_route("/suggest", api_suggest, methods=["GET"])
|
|
|
|
|
|
v2.add_api_route("/place", api_place, methods=["GET"])
|
|
|
|
|
|
v2.add_api_route("/grade", api_grade, methods=["GET"])
|
|
|
|
|
|
v2.add_api_route("/calendar", api_calendar, methods=["GET"])
|
|
|
|
|
|
v2.add_api_route("/day", api_day, methods=["GET"])
|
|
|
|
|
|
v2.add_api_route("/score", api_score, methods=["GET"])
|
|
|
|
|
|
v2.add_api_route("/forecast", api_forecast, methods=["GET"])
|
|
|
|
|
|
v2.add_api_route("/cell", api_cell, methods=["GET"])
|
|
|
|
|
|
v2.add_api_route("/metrics", api_metrics, methods=["GET"], include_in_schema=False)
|
|
|
|
|
|
v2.add_api_route("/event", api_event, methods=["POST"], include_in_schema=False)
|
|
|
|
|
|
|
|
|
|
|
|
app.include_router(v1, prefix=f"{BASE}/api") # legacy unversioned == v1 (backward compatible)
|
|
|
|
|
|
app.include_router(v1, prefix=f"{BASE}/api/v1")
|
|
|
|
|
|
app.include_router(v2, prefix=f"{BASE}/api/v2")
|
2026-07-21 20:01:30 +00:00
|
|
|
|
# SSR content API (repo-split Stage 2), now consumed by the frontend_ssr
|
|
|
|
|
|
# service (Stage 3) instead of anything in this process. See
|
2026-07-21 16:21:28 +00:00
|
|
|
|
# backend/api/content_routes.py.
|
|
|
|
|
|
app.include_router(content_routes.router, prefix=f"{BASE}/api/v2")
|
daemon: move the Discord gateway and scheduler out of the web process into Go (#21)
The gateway bot and APScheduler were long-lived stateful I/O loops running
inside the async web app under a leader election. They move into a single Go
binary that owns ONLY that I/O -- websocket, RESUME, heartbeat, backoff, timers.
It owns no grading logic. Anything needing data calls back over a new
internal-only surface (/internal/discord/grade, /internal/jobs/*). Grading
depends on polars and the parquet cache; reimplementing it in Go would let the
bot's grades drift from the API's. The grade route returns gateway-ready JSON
and Go relays the bytes verbatim.
The binary ships in the backend image and runs as a second compose service off
the same tag, so the two ends of the /internal/* contract can never skew.
deploy.sh rolls daemon alongside backend -- without that the service would never
be created, since a single-service deploy uses --no-deps. It also probes the
image first and skips the daemon when rolling a tag that predates the binary:
infra tracks main while image tags are env-staged, so a host can legitimately be
asked to roll an older backend image, and creating the service anyway would
leave a container crash-looping on a missing binary.
replicas: 1 with order: stop-first replaces the leader election -- Discord
permits one gateway connection per bot token.
THERMOGRAPH_INTERNAL_TOKEN is optional: both ends derive it from
THERMOGRAPH_AUTH_SECRET via HMAC under a domain-separation label, so this needs
no new vault entry. The derivation is pinned to a shared cross-language test
vector asserted on both sides, so drift fails CI instead of 401ing every call.
Fail closed when neither secret is set.
Improvements over the Python: a close intended for RESUME uses 4000 rather than
1000 (Discord invalidates a session closed 1000, so the old default defeated its
own resume); MESSAGE_CREATE runs on a bounded worker pool; and a malformed HELLO
returns an error rather than a clean reconnect, which would otherwise reset
backoff and hot-loop against the gateway.
365 Python tests pass; Go build/vet/test -race clean; shellcheck 0 findings.
2026-07-23 22:49:54 +00:00
|
|
|
|
# Internal control surface for the thermograph-daemon Go binary (the gateway
|
|
|
|
|
|
# bot + recurring jobs that used to run in _lifespan). Deliberately NOT under
|
|
|
|
|
|
# BASE — the daemon hits THERMOGRAPH_API_BASE_INTERNAL directly, same posture
|
|
|
|
|
|
# as /healthz — and never publicly routed (the Caddyfile only forwards
|
|
|
|
|
|
# /api/*, /digest and /discord/interactions here). Registered before the
|
|
|
|
|
|
# catch-all frontend proxy below so /internal/* can't fall through to it.
|
|
|
|
|
|
app.include_router(internal_routes.router)
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 20:01:30 +00:00
|
|
|
|
# --- proxy to the frontend SSR service (repo-split Stage 4) ------------------
|
|
|
|
|
|
_PROXY_EXCLUDED_REQUEST_HEADERS = {"host", "content-length"}
|
|
|
|
|
|
_PROXY_EXCLUDED_RESPONSE_HEADERS = {"content-encoding", "content-length", "transfer-encoding", "connection"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _proxy_to_frontend(request: Request) -> Response:
|
|
|
|
|
|
"""Forward a GET/HEAD request verbatim to the frontend SSR service and relay
|
|
|
|
|
|
its response back untouched (status, body, headers -- including ETag, so
|
|
|
|
|
|
frontend's own If-None-Match/304 logic keeps working through this hop).
|
|
|
|
|
|
Every frontend-owned path (content.register()'s old routes) is read-only,
|
|
|
|
|
|
so this never needs to forward a request body.
|
|
|
|
|
|
|
|
|
|
|
|
The primary same-origin mechanism is host Caddy in prod/beta -- it
|
|
|
|
|
|
path-splits directly to backend vs frontend, so this proxy is never
|
|
|
|
|
|
actually exercised there. This is the fallback for every environment with
|
|
|
|
|
|
no Caddy in front (LAN dev, bare-metal run.sh) -- see the repo-split
|
|
|
|
|
|
plan's Stage 4 section."""
|
|
|
|
|
|
headers = {k: v for k, v in request.headers.items()
|
|
|
|
|
|
if k.lower() not in _PROXY_EXCLUDED_REQUEST_HEADERS}
|
2026-07-21 20:52:11 +00:00
|
|
|
|
# Host is deliberately excluded above (it'd otherwise target this internal
|
|
|
|
|
|
# hop, not the browser-facing address) -- forward it as X-Forwarded-Host
|
|
|
|
|
|
# instead, so frontend_ssr's own _origin() can still build correct
|
|
|
|
|
|
# absolute URLs (canonical, og:url, asset_base_url) instead of resolving
|
|
|
|
|
|
# to its own internal address. Chain through an existing value first, the
|
|
|
|
|
|
# same precedence _origin() itself uses for the proto.
|
|
|
|
|
|
headers["x-forwarded-host"] = request.headers.get("x-forwarded-host") or request.headers.get("host") or request.url.netloc
|
|
|
|
|
|
headers["x-forwarded-proto"] = request.headers.get("x-forwarded-proto") or request.url.scheme
|
2026-07-21 20:01:30 +00:00
|
|
|
|
upstream = await _frontend_client.request(
|
|
|
|
|
|
request.method, request.url.path, params=request.url.query, headers=headers)
|
|
|
|
|
|
resp_headers = {k: v for k, v in upstream.headers.items()
|
|
|
|
|
|
if k.lower() not in _PROXY_EXCLUDED_RESPONSE_HEADERS}
|
|
|
|
|
|
return Response(content=upstream.content, status_code=upstream.status_code, headers=resp_headers)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 22:48:59 +00:00
|
|
|
|
# The catch-all fallback registered at the very end of this file (after every
|
|
|
|
|
|
# backend route) proxies everything else -- content pages, the IndexNow key
|
|
|
|
|
|
# file, static assets, the SPA shells -- to frontend. See it there for why a
|
|
|
|
|
|
# single fallback replaced what used to be an enumerated path list (repo-split
|
|
|
|
|
|
# Stage 7a).
|
2026-07-21 20:01:30 +00:00
|
|
|
|
|
|
|
|
|
|
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
# --- Discord slash commands (HTTP interactions) ----------------------------
|
|
|
|
|
|
async def discord_interactions(request: Request) -> Response:
|
|
|
|
|
|
"""Discord posts each slash-command interaction here. Verification runs over the
|
|
|
|
|
|
RAW body, so this must read bytes, not request.json()."""
|
|
|
|
|
|
raw = await request.body()
|
2026-07-22 19:07:15 +00:00
|
|
|
|
# handle() does real work for /grade (sync parquet/DB reads + grading math),
|
|
|
|
|
|
# not just the cheap signature check — keep it off the event loop.
|
|
|
|
|
|
status, payload = await run_in_threadpool(
|
|
|
|
|
|
discord_interactions_mod.handle,
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
raw,
|
|
|
|
|
|
request.headers.get("x-signature-ed25519", ""),
|
|
|
|
|
|
request.headers.get("x-signature-timestamp", ""),
|
|
|
|
|
|
)
|
|
|
|
|
|
return Response(json.dumps(payload), status_code=status, media_type="application/json")
|
|
|
|
|
|
|
|
|
|
|
|
app.add_api_route(f"{BASE}/discord/interactions", discord_interactions,
|
|
|
|
|
|
methods=["POST"], include_in_schema=False)
|
|
|
|
|
|
|
|
|
|
|
|
# --- accounts (fastapi-users) ----------------------------------------------
|
|
|
|
|
|
# Library-provided routers: /auth/login + /auth/logout (cookie session),
|
2026-07-21 03:57:20 +00:00
|
|
|
|
# /auth/register (signup), /auth/verify + /auth/request-verify-token (email
|
|
|
|
|
|
# confirmation), and /users/me (session check / profile). All under the same
|
|
|
|
|
|
# v2 prefix as the rest of the API, so the frontend calls them base-relative.
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
app.include_router(
|
|
|
|
|
|
users.fastapi_users.get_auth_router(users.auth_backend),
|
|
|
|
|
|
prefix=f"{BASE}/api/v2/auth", tags=["auth"],
|
|
|
|
|
|
)
|
|
|
|
|
|
app.include_router(
|
|
|
|
|
|
users.fastapi_users.get_register_router(UserRead, UserCreate),
|
|
|
|
|
|
prefix=f"{BASE}/api/v2/auth", tags=["auth"],
|
|
|
|
|
|
)
|
2026-07-21 03:57:20 +00:00
|
|
|
|
app.include_router(
|
|
|
|
|
|
users.fastapi_users.get_verify_router(UserRead),
|
|
|
|
|
|
prefix=f"{BASE}/api/v2/auth", tags=["auth"],
|
|
|
|
|
|
)
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
app.include_router(
|
|
|
|
|
|
users.fastapi_users.get_users_router(UserRead, UserUpdate),
|
|
|
|
|
|
prefix=f"{BASE}/api/v2/users", tags=["users"],
|
|
|
|
|
|
)
|
|
|
|
|
|
# Subscriptions + notifications (authed, user-scoped).
|
|
|
|
|
|
app.include_router(api_accounts.router, prefix=f"{BASE}/api/v2")
|
2026-07-27 00:56:43 +00:00
|
|
|
|
# Sign in with / connect an external provider (Discord, Google).
|
|
|
|
|
|
app.include_router(oauth.router, prefix=f"{BASE}/api/v2")
|
|
|
|
|
|
# Discord's legacy /discord/* paths: the callback URL registered in Discord's
|
|
|
|
|
|
# portal, plus what the currently-deployed frontend calls. Mounted AFTER the
|
|
|
|
|
|
# generic router so neither shadows the other — the paths are disjoint, and
|
|
|
|
|
|
# /oauth/{provider}/... would otherwise be a candidate match for /discord/....
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
app.include_router(discord_link.router, prefix=f"{BASE}/api/v2")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# The un-slashed base redirects to BASE/ so the frontend's relative asset URLs
|
|
|
|
|
|
# resolve correctly. Under a sub-path this keeps the app scoped to BASE (never
|
|
|
|
|
|
# claiming "/", so the domain root stays free for another app via the proxy). When
|
|
|
|
|
|
# BASE is "" the app owns the root: "/" is already the index route below, so there
|
|
|
|
|
|
# is no bare base to redirect (and "" is not a valid route path).
|
|
|
|
|
|
if BASE:
|
|
|
|
|
|
app.add_api_route(BASE, lambda: RedirectResponse(url=f"{BASE}/"), methods=["GET", "HEAD"], include_in_schema=False)
|
|
|
|
|
|
|
|
|
|
|
|
# Monthly-digest signup (the footer form on every page). Answers the same generic
|
|
|
|
|
|
# message whatever the outcome, so it can't be probed for whether an address is
|
|
|
|
|
|
# already subscribed.
|
|
|
|
|
|
app.add_api_route(f"{BASE}/digest", digest.signup, methods=["POST"], include_in_schema=False)
|
|
|
|
|
|
|
2026-07-21 22:48:59 +00:00
|
|
|
|
# --- everything else: proxy to frontend (repo-split Stage 7a) ---------------
|
|
|
|
|
|
# Frontend now owns every page (the SSR content pages, and the interactive
|
|
|
|
|
|
# tool's SPA shells), every static asset (app.js, style.css, manifest,
|
|
|
|
|
|
# favicons, …), and the dynamic IndexNow key file -- backend serves none of
|
|
|
|
|
|
# that itself anymore (it used to read them from a sibling frontend/
|
|
|
|
|
|
# directory; see _proxy_to_frontend's own docstring for why that stops being
|
|
|
|
|
|
# true once backend and frontend are separate repos). A single catch-all,
|
|
|
|
|
|
# registered dead last so every real backend route above already won, proxies
|
|
|
|
|
|
# anything unmatched -- simpler than (and a superset of) the enumerated path
|
|
|
|
|
|
# list this replaced, since static asset filenames aren't enumerable the way
|
|
|
|
|
|
# content pages are. Caddy path-splits directly to whichever service owns a
|
|
|
|
|
|
# path in prod/beta (deploy/Caddyfile), so this is only ever exercised as the
|
|
|
|
|
|
# fallback for environments with no Caddy in front (LAN dev, bare-metal
|
|
|
|
|
|
# run.sh) -- same role _proxy_to_frontend has always had.
|
|
|
|
|
|
app.add_api_route(f"{BASE}/{{path:path}}", _proxy_to_frontend, methods=["GET", "HEAD"], include_in_schema=False)
|