The homepage was a bare tool: a find-bar and an empty panel reading "Find a location to begin." A visitor arriving from a search result or a shared link learned nothing about what the product does before deciding to leave. Rebuild it around the Weekly view, which is untouched: - Hero with the question as the h1, and a grade card showing a real graded example — the most unusual city we're currently tracking, either tail. The card's frame and text slots are server-rendered with reserved heights, so app.js re-pointing it at the visitor's own place shifts nothing. - "Unusual right now" strip, CSS scroll-snap, no JS carousel. A cold-tail city is force-included whenever one qualifies. - Stance line, how-it-works, explore cards, and 12 city chips linking into the ~1000-page /climate surface. - Monthly digest form, in the footer of every page. Serve / from Jinja instead of a static file with placeholder substitution, so crawlers and no-JS readers get the whole page as real HTML. home.html.j2 extends base.html.j2 and carries app.js's DOM contract over verbatim; the brand degrades to a <p> so the hero owns the sole h1. frontend/index.html is deleted rather than left behind the static mount, where it would keep serving indexable duplicate content. "Where is it most unusual right now" has no cheap answer at request time — percentiles live inside zlib-compressed payload blobs with no column to sort on. So homepage.py sweeps the warm cache and writes data/homepage.json, read by the template. The sweep is strictly cache-only (climate.load_cached_recent_forecast is new, the sibling of load_cached_history), so grading ~1000 cities costs zero upstream requests. It rides the notifier's timer behind an hourly guard rather than starting a second daemon, and also runs at the tail of warm_cities so a fresh deploy has a populated feed. Instrumentation: metrics gains a product-event counter keyed by (event, referrer domain, UTC day), behind an allowlist and a per-IP rate limit, fed by POST /api/v2/event. The referrer is taken from the request's own header, never from the client. The beacon gets its own inbound category that record_inbound ignores, so reporting an interaction doesn't also count as traffic. The dashboard grows an events block with per-referrer attribution. Email is scaffolded but sends nothing yet. mailer.py talks stdlib smtplib to a local Postfix null client on 127.0.0.1:25 (deploy/provision-mail.sh), so the choice between direct-to-MX and relaying through a provider stays a Postfix config change with no code change. The backend defaults to "console", which logs and sends nothing, so dev and tests exercise the whole signup path safely. Signups land in pending_digest unconfirmed; collecting the list shouldn't wait on delivery. Also adds /privacy, linked from the footer and kept out of the sitemap. The strip's classes are named unusual-* rather than record-*: .record-card is already the SEO records page's, and reusing it leaked layout rules onto /climate/<slug>/records.
730 lines
34 KiB
Python
730 lines
34 KiB
Python
"""Thermograph API — grade recent local weather against ~45 years of climatology."""
|
||
import contextlib
|
||
import datetime
|
||
import functools
|
||
import hashlib
|
||
import hmac
|
||
import json
|
||
import mimetypes
|
||
import os
|
||
import queue
|
||
import threading
|
||
import time
|
||
|
||
from fastapi import APIRouter, FastAPI, HTTPException, Query, Request, Response
|
||
from fastapi.middleware.gzip import GZipMiddleware
|
||
from fastapi.responses import RedirectResponse
|
||
from fastapi.staticfiles import StaticFiles
|
||
|
||
import api_accounts
|
||
import audit
|
||
import climate
|
||
import content
|
||
import digest
|
||
import db
|
||
import grid
|
||
import metrics
|
||
import notify
|
||
import places
|
||
import singleton
|
||
import store
|
||
import users
|
||
import views
|
||
from schemas import UserCreate, UserRead, UserUpdate
|
||
|
||
FRONTEND_DIR = os.path.join(os.path.dirname(__file__), "..", "frontend")
|
||
|
||
# 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 ""
|
||
|
||
# The PWA manifest is served as a static file; register its media type so
|
||
# StaticFiles labels it correctly (not in Python's default mimetypes map).
|
||
mimetypes.add_type("application/manifest+json", ".webmanifest")
|
||
|
||
|
||
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}")
|
||
|
||
|
||
# --- 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"])
|
||
token = views.history_token(history)
|
||
start_ts, end_ts = views.cal_span(history, None, None, 24)
|
||
cal_key = views.calendar_key(start_ts, end_ts, 24)
|
||
if store.get_payload("calendar", cell["id"], cal_key, token) is None:
|
||
store.put_payload("calendar", cell["id"], cal_key, token,
|
||
views.build_calendar(cell, history, start_ts, end_ts, 24, place))
|
||
last = history["date"].max()
|
||
day_key = views.day_key(last)
|
||
if store.get_payload("day", cell["id"], day_key, token) is None:
|
||
store.put_payload("day", cell["id"], day_key, token,
|
||
views.build_day(cell, history, last, place))
|
||
|
||
|
||
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()
|
||
|
||
|
||
@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()
|
||
# Background subscription evaluator. Its periodic sweep fetches upstream on a timer
|
||
# (not per request), so with multiple workers it must run in exactly one — elect a
|
||
# leader via a lockfile (THERMOGRAPH_SINGLETON_LOCK). Unset (single worker / dev /
|
||
# tests) => always the leader. The neighbor warmer above stays per-worker: each
|
||
# drains its own request-fed queue.
|
||
if singleton.claim(os.environ.get("THERMOGRAPH_SINGLETON_LOCK")):
|
||
notify.start()
|
||
yield
|
||
notify.stop()
|
||
|
||
|
||
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.
|
||
app.add_middleware(GZipMiddleware, minimum_size=1024)
|
||
|
||
|
||
def _client_ip(request) -> "str | None":
|
||
"""The real client IP: the left-most X-Forwarded-For hop when a proxy fronts us
|
||
(Caddy on prod sets it), otherwise the direct peer address (LAN dev)."""
|
||
xff = request.headers.get("x-forwarded-for")
|
||
if xff:
|
||
return xff.split(",")[0].strip()
|
||
return request.client.host if request.client else None
|
||
|
||
|
||
@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)."""
|
||
response = await call_next(request)
|
||
path = request.url.path
|
||
try:
|
||
cat = metrics.classify_inbound(path, BASE)
|
||
metrics.record_inbound(cat, response.status_code)
|
||
# Retain per-request client IPs for later analysis; skip static assets and the
|
||
# dashboard's own metrics polling to keep the log to real, meaningful traffic.
|
||
if cat not in ("static", "metrics", "event"):
|
||
audit.log_access({"ip": _client_ip(request), "method": request.method,
|
||
"path": path, "status": response.status_code, "cat": cat})
|
||
except Exception: # noqa: BLE001 - never let instrumentation break a response
|
||
pass
|
||
pages = (BASE, f"{BASE}/", f"{BASE}/calendar", f"{BASE}/day", 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.
|
||
# The payloads themselves are assembled in views.py, shared with the offline
|
||
# migrate script.
|
||
|
||
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 ----------------------------------------------------------------
|
||
|
||
def api_geocode(q: str = Query(..., min_length=1)):
|
||
try:
|
||
return {"results": climate.geocode(q)}
|
||
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
|
||
|
||
|
||
@functools.lru_cache(maxsize=1024)
|
||
def _suggest_upstream(q: str) -> tuple:
|
||
"""Open-Meteo lookup for /suggest, memoized per query string — type-ahead
|
||
re-asks the same prefixes constantly (backspacing, retyping). Failures
|
||
raise and are not cached, so a transient upstream error doesn't stick."""
|
||
return tuple(climate.geocode(q, count=_SUGGEST_LIMIT))
|
||
|
||
|
||
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
|
||
lives in places.suggest."""
|
||
try:
|
||
results, corrected = places.suggest(q, _suggest_upstream, _SUGGEST_LIMIT)
|
||
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)"),
|
||
):
|
||
target = datetime.date.fromisoformat(date[:10]) if date else datetime.date.today()
|
||
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,
|
||
views.grade_key(target, days, after), views.recent_token(history, cell["id"]),
|
||
lambda place: views.build_grade(cell, target, days, history, recent,
|
||
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)
|
||
start_ts, end_ts = views.cal_span(history, start, end, months)
|
||
|
||
def build(place):
|
||
payload = views.build_calendar(cell, history, start_ts, end_ts, months, place, run)
|
||
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,
|
||
views.calendar_key(start_ts, end_ts, months),
|
||
views.history_token(history), build,
|
||
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()
|
||
target = datetime.date.fromisoformat(date[:10]) if date else last
|
||
run.set(target_date=target.isoformat())
|
||
|
||
def build(place):
|
||
payload = views.build_day(cell, history, target, place, run)
|
||
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,
|
||
views.day_key(target), views.day_token(history, target),
|
||
build)
|
||
|
||
|
||
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):
|
||
payload = views.build_forecast(cell, days, history, fc, today, place, run)
|
||
run.set(run_type="partial")
|
||
return payload
|
||
|
||
return _cached_response(request, run, "forecast", cell,
|
||
views.forecast_key(today, days),
|
||
views.recent_token(history, cell["id"]), build)
|
||
|
||
|
||
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 = {}
|
||
hist_token = views.history_token(history)
|
||
# Calendar: the default last-24-months span — what the calendar view (and
|
||
# the cross-view prefetch) requests first.
|
||
start_ts, end_ts = views.cal_span(history, None, None, 24)
|
||
slices["calendar"] = slice_for(
|
||
"calendar", views.calendar_key(start_ts, end_ts, 24), hist_token,
|
||
lambda: views.build_calendar(cell, history, start_ts, end_ts, 24, place, run))
|
||
|
||
if prefetch:
|
||
# Latest archived day: its observation comes from history alone, so
|
||
# it's buildable without the (skipped) recent bundle.
|
||
slices["day"] = slice_for(
|
||
"day", views.day_key(last), hist_token,
|
||
lambda: views.build_day(cell, history, last, place, run))
|
||
else:
|
||
rf_token = views.recent_token(history, cid)
|
||
slices["grade"] = slice_for(
|
||
"grade", views.grade_key(today, 14, 14), rf_token,
|
||
lambda: views.build_grade(cell, today, 14, history, recent, cache_meta, place, run, after=14))
|
||
slices["forecast"] = slice_for(
|
||
"forecast", views.forecast_key(today, 7), rf_token,
|
||
lambda: views.build_forecast(cell, 7, history, recent, today, place, run))
|
||
slices["day"] = slice_for(
|
||
"day", views.day_key(today), views.day_token(history, today),
|
||
lambda: views.build_day(cell, history, today, place, run, recent=recent))
|
||
|
||
# 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:
|
||
metrics.record_event(
|
||
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("/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")
|
||
|
||
# --- accounts (fastapi-users) ----------------------------------------------
|
||
# Library-provided routers: /auth/login + /auth/logout (cookie session),
|
||
# /auth/register (signup), 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.
|
||
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"],
|
||
)
|
||
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")
|
||
|
||
|
||
# --- static frontend (served under BASE) -----------------------------------
|
||
def _page(name):
|
||
"""Route handler for one HTML page. Serves the file with its __ORIGIN__
|
||
placeholders (the link-preview/Open Graph tags) filled in as the request's
|
||
scheme://host + BASE — preview crawlers (Discord, Slack, …) need absolute
|
||
URLs, and the host differs between LAN and prod. The scheme comes from
|
||
X-Forwarded-Proto when a reverse proxy (Caddy) fronts the plain-HTTP
|
||
uvicorn; the proxy passes the original Host header through untouched."""
|
||
path = os.path.join(FRONTEND_DIR, name)
|
||
|
||
def route(request: Request):
|
||
with open(path, encoding="utf-8") as f:
|
||
html = f.read()
|
||
proto = request.headers.get("x-forwarded-proto") or request.url.scheme
|
||
host = request.headers.get("host") or request.url.netloc
|
||
html = html.replace("__ORIGIN__", f"{proto}://{host}{BASE}")
|
||
# Search-engine verification <meta> tags (same source as the SEO pages), so
|
||
# the homepage Google/Bing verify against carries them too.
|
||
verify = content.head_verify_html()
|
||
if verify:
|
||
html = html.replace("<head>", f"<head>\n {verify}", 1)
|
||
etag = f'W/"{hashlib.sha1(html.encode()).hexdigest()[:20]}"'
|
||
if _not_modified(request, etag):
|
||
return Response(status_code=304, headers={"ETag": etag})
|
||
return Response(html, media_type="text/html", headers={"ETag": etag})
|
||
return route
|
||
|
||
|
||
# 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).
|
||
# Pages also answer HEAD — link-preview crawlers probe with it before fetching.
|
||
if BASE:
|
||
app.add_api_route(BASE, lambda: RedirectResponse(url=f"{BASE}/"), methods=["GET", "HEAD"], include_in_schema=False)
|
||
# NOTE: "/" is not here — the homepage is server-rendered by content.register()
|
||
# below, so its hero, records strip and city links reach crawlers and no-JS
|
||
# readers as real HTML.
|
||
app.add_api_route(f"{BASE}/calendar", _page("calendar.html"), methods=["GET", "HEAD"], include_in_schema=False)
|
||
app.add_api_route(f"{BASE}/day", _page("day.html"), methods=["GET", "HEAD"], include_in_schema=False)
|
||
app.add_api_route(f"{BASE}/compare", _page("compare.html"), methods=["GET", "HEAD"], include_in_schema=False)
|
||
app.add_api_route(f"{BASE}/legend", _page("legend.html"), methods=["GET", "HEAD"], include_in_schema=False)
|
||
app.add_api_route(f"{BASE}/alerts", _page("subscriptions.html"), 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)
|
||
|
||
# Crawlable, server-rendered SEO pages (the homepage, climate hub / per-city /
|
||
# month / records / glossary / about / privacy) + robots.txt + sitemap.xml.
|
||
# Registered before the static mount so these explicit routes win.
|
||
content.register(app)
|
||
|
||
# Everything else under BASE (app.js, style.css, nav.js, …) is a static asset.
|
||
# Registered last so the explicit page routes above win. Mounts must start with
|
||
# "/", so at the root (BASE == "") mount at "/" — the earlier routes still win
|
||
# because Starlette matches in registration order.
|
||
app.mount(BASE or "/", StaticFiles(directory=FRONTEND_DIR), name="static")
|