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
This commit is contained in:
parent
af57b89e4b
commit
d17ac794fd
58 changed files with 1001 additions and 931 deletions
1
accounts/__init__.py
Normal file
1
accounts/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""accounts package."""
|
||||||
|
|
@ -12,12 +12,12 @@ from fastapi.concurrency import run_in_threadpool
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
import climate
|
from data import climate
|
||||||
import grid
|
from data import grid
|
||||||
import push
|
from notifications import push
|
||||||
from db import get_async_session
|
from accounts.db import get_async_session
|
||||||
from models import Notification, PushSubscription, Subscription
|
from accounts.models import Notification, PushSubscription, Subscription
|
||||||
from schemas import (
|
from web.schemas import (
|
||||||
NotificationList,
|
NotificationList,
|
||||||
NotificationOut,
|
NotificationOut,
|
||||||
PushSubscriptionIn,
|
PushSubscriptionIn,
|
||||||
|
|
@ -26,7 +26,7 @@ from schemas import (
|
||||||
SubscriptionOut,
|
SubscriptionOut,
|
||||||
SubscriptionPatch,
|
SubscriptionPatch,
|
||||||
)
|
)
|
||||||
from users import current_active_user
|
from accounts.users import current_active_user
|
||||||
|
|
||||||
_BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/")
|
_BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/")
|
||||||
BASE = f"/{_BASE}" if _BASE else ""
|
BASE = f"/{_BASE}" if _BASE else ""
|
||||||
|
|
@ -23,11 +23,11 @@ from sqlalchemy import create_engine, event
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||||
|
|
||||||
|
import paths
|
||||||
|
|
||||||
# Default to data/accounts.sqlite; THERMOGRAPH_ACCOUNTS_DB overrides it (tests point
|
# Default to data/accounts.sqlite; THERMOGRAPH_ACCOUNTS_DB overrides it (tests point
|
||||||
# this at a throwaway temp file to stay hermetic).
|
# this at a throwaway temp file to stay hermetic).
|
||||||
DB_PATH = os.environ.get("THERMOGRAPH_ACCOUNTS_DB") or os.path.abspath(
|
DB_PATH = os.environ.get("THERMOGRAPH_ACCOUNTS_DB") or os.path.join(paths.DATA_DIR, "accounts.sqlite")
|
||||||
os.path.join(os.path.dirname(__file__), "..", "data", "accounts.sqlite")
|
|
||||||
)
|
|
||||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -71,7 +71,7 @@ async def create_db_and_tables() -> None:
|
||||||
Imported for its side effect of registering the mapped classes on Base.metadata
|
Imported for its side effect of registering the mapped classes on Base.metadata
|
||||||
before create_all runs.
|
before create_all runs.
|
||||||
"""
|
"""
|
||||||
import models # noqa: F401 (registers tables on Base.metadata)
|
from accounts import models # noqa: F401 (registers tables on Base.metadata)
|
||||||
|
|
||||||
async with async_engine.begin() as conn:
|
async with async_engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
@ -25,7 +25,7 @@ from sqlalchemy import (
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from db import Base
|
from accounts.db import Base
|
||||||
|
|
||||||
|
|
||||||
class User(SQLAlchemyBaseUserTableUUID, Base):
|
class User(SQLAlchemyBaseUserTableUUID, Base):
|
||||||
|
|
@ -16,8 +16,8 @@ from fastapi_users_db_sqlalchemy import SQLAlchemyUserDatabase
|
||||||
from fastapi_users_db_sqlalchemy.access_token import SQLAlchemyAccessTokenDatabase
|
from fastapi_users_db_sqlalchemy.access_token import SQLAlchemyAccessTokenDatabase
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from db import get_async_session
|
from accounts.db import get_async_session
|
||||||
from models import AccessToken, User
|
from accounts.models import AccessToken, User
|
||||||
|
|
||||||
BASE = "/" + os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/")
|
BASE = "/" + os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/")
|
||||||
# Only used to sign reset/verification tokens — features that stay dormant until
|
# Only used to sign reset/verification tokens — features that stay dormant until
|
||||||
787
app.py
787
app.py
|
|
@ -1,782 +1,7 @@
|
||||||
"""Thermograph API — grade recent local weather against ~45 years of climatology."""
|
"""Entry-point shim.
|
||||||
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
|
The real application lives in ``web/app.py``. This re-export keeps the launch
|
||||||
from fastapi.middleware.gzip import GZipMiddleware
|
target stable at ``app:app`` (run from ``backend/``), so the systemd units,
|
||||||
from fastapi.responses import RedirectResponse
|
run.sh, and CI need no change after the move into packages.
|
||||||
from fastapi.staticfiles import StaticFiles
|
"""
|
||||||
|
from web.app import app # noqa: F401
|
||||||
import api_accounts
|
|
||||||
import audit
|
|
||||||
import climate
|
|
||||||
import content
|
|
||||||
import digest
|
|
||||||
import discord_interactions as discord_interactions_mod
|
|
||||||
import discord_link
|
|
||||||
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}/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.
|
|
||||||
# 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_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):
|
|
||||||
payload = views.build_score(cell, history, 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, "score", cell,
|
|
||||||
views.score_key(), views.history_token(history), 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("/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")
|
|
||||||
|
|
||||||
|
|
||||||
# --- 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()
|
|
||||||
status, payload = discord_interactions_mod.handle(
|
|
||||||
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),
|
|
||||||
# /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")
|
|
||||||
# Discord account linking (OAuth2, authed).
|
|
||||||
app.include_router(discord_link.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}/score", _page("score.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")
|
|
||||||
|
|
|
||||||
1
core/__init__.py
Normal file
1
core/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""core package."""
|
||||||
|
|
@ -23,7 +23,9 @@ import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
LOG_ROOT = os.path.join(os.path.dirname(__file__), "..", "logs")
|
import paths
|
||||||
|
|
||||||
|
LOG_ROOT = paths.LOGS_DIR
|
||||||
AUDIT_DIR = os.path.join(LOG_ROOT, "audit")
|
AUDIT_DIR = os.path.join(LOG_ROOT, "audit")
|
||||||
ERROR_DIR = os.path.join(LOG_ROOT, "errors")
|
ERROR_DIR = os.path.join(LOG_ROOT, "errors")
|
||||||
ACCESS_DIR = os.path.join(LOG_ROOT, "access")
|
ACCESS_DIR = os.path.join(LOG_ROOT, "access")
|
||||||
1
data/__init__.py
Normal file
1
data/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""data package."""
|
||||||
|
|
@ -3,8 +3,10 @@ climate pages. Loaded once, lazily; regenerate the JSON with gen_cities.py."""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
|
||||||
_PATH = os.path.join(os.path.dirname(__file__), "cities.json")
|
import paths
|
||||||
_FLAVOR_PATH = os.path.join(os.path.dirname(__file__), "cities_flavor.json")
|
|
||||||
|
_PATH = paths.CITIES_JSON
|
||||||
|
_FLAVOR_PATH = paths.CITIES_FLAVOR_JSON
|
||||||
_CITIES: list[dict] | None = None
|
_CITIES: list[dict] | None = None
|
||||||
_BY_SLUG: dict[str, dict] | None = None
|
_BY_SLUG: dict[str, dict] | None = None
|
||||||
_FLAVOR: dict[str, dict] | None = None
|
_FLAVOR: dict[str, dict] | None = None
|
||||||
|
|
@ -14,11 +14,12 @@ import httpx
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import polars as pl
|
import polars as pl
|
||||||
|
|
||||||
import audit
|
from core import audit
|
||||||
import metrics
|
from core import metrics
|
||||||
import store
|
import paths
|
||||||
|
from data import store
|
||||||
|
|
||||||
CACHE_DIR = os.path.join(os.path.dirname(__file__), "..", "data", "cache")
|
CACHE_DIR = os.path.join(paths.DATA_DIR, "cache")
|
||||||
MAX_ATTEMPTS = 3 # per upstream call, before giving up
|
MAX_ATTEMPTS = 3 # per upstream call, before giving up
|
||||||
START_DATE = "1980-01-01" # ERA5 reaches back to 1940; 1980 = 45 yrs, fast + robust
|
START_DATE = "1980-01-01" # ERA5 reaches back to 1940; 1980 = 45 yrs, fast + robust
|
||||||
ARCHIVE_LATENCY_DAYS = 6 # ERA5 archive lags real time by a few days
|
ARCHIVE_LATENCY_DAYS = 6 # ERA5 archive lags real time by a few days
|
||||||
|
|
@ -28,9 +28,10 @@ import zipfile
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
import audit
|
from core import audit
|
||||||
|
import paths
|
||||||
|
|
||||||
GEO_DIR = os.path.join(os.path.dirname(__file__), "..", "data", "geonames")
|
GEO_DIR = os.path.join(paths.DATA_DIR, "geonames")
|
||||||
GEONAMES_URL = "https://download.geonames.org/export/dump/"
|
GEONAMES_URL = "https://download.geonames.org/export/dump/"
|
||||||
|
|
||||||
# Which GeoNames cities dump to index. cities1000 (~170k places, population
|
# Which GeoNames cities dump to index. cities1000 (~170k places, population
|
||||||
|
|
@ -18,7 +18,7 @@ payload (``baseline_overlaps_recent``).
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import polars as pl
|
import polars as pl
|
||||||
|
|
||||||
import grading
|
from data import grading
|
||||||
|
|
||||||
RECENT_YEARS = 6
|
RECENT_YEARS = 6
|
||||||
QS = (10, 25, 50, 75, 90) # the scored percentile categories
|
QS = (10, 25, 50, 75, 90) # the scored percentile categories
|
||||||
|
|
@ -26,7 +26,9 @@ import threading
|
||||||
import time
|
import time
|
||||||
import zlib
|
import zlib
|
||||||
|
|
||||||
DB_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "thermograph.sqlite")
|
import paths
|
||||||
|
|
||||||
|
DB_PATH = os.path.join(paths.DATA_DIR, "thermograph.sqlite")
|
||||||
|
|
||||||
_SCHEMA = """
|
_SCHEMA = """
|
||||||
CREATE TABLE IF NOT EXISTS derived (
|
CREATE TABLE IF NOT EXISTS derived (
|
||||||
|
|
@ -15,9 +15,10 @@ import re
|
||||||
import sys
|
import sys
|
||||||
import unicodedata
|
import unicodedata
|
||||||
|
|
||||||
import places
|
import paths
|
||||||
|
from data import places
|
||||||
|
|
||||||
OUT_PATH = os.path.join(os.path.dirname(__file__), "cities.json")
|
OUT_PATH = paths.CITIES_JSON
|
||||||
|
|
||||||
# GeoNames entry tuple layout (see places._load): the fields we keep.
|
# GeoNames entry tuple layout (see places._load): the fields we keep.
|
||||||
_NAME, _ADMIN1, _COUNTRY, _CC, _LAT, _LON, _POP = 1, 2, 3, 4, 5, 6, 7
|
_NAME, _ADMIN1, _COUNTRY, _CC, _LAT, _LON, _POP = 1, 2, 3, 4, 5, 6, 7
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,10 @@ from urllib.parse import quote
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
import cities
|
from data import cities
|
||||||
|
import paths
|
||||||
|
|
||||||
OUT_PATH = os.path.join(os.path.dirname(__file__), "cities_flavor.json")
|
OUT_PATH = paths.CITIES_FLAVOR_JSON
|
||||||
UA = "ThermographBot/1.0 (https://thermograph.org; climate comparison tool)"
|
UA = "ThermographBot/1.0 (https://thermograph.org; climate comparison tool)"
|
||||||
SUMMARY = "https://en.wikipedia.org/api/rest_v1/page/summary/"
|
SUMMARY = "https://en.wikipedia.org/api/rest_v1/page/summary/"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,11 @@ from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
import paths
|
||||||
|
|
||||||
log = logging.getLogger("thermograph.indexnow")
|
log = logging.getLogger("thermograph.indexnow")
|
||||||
|
|
||||||
_DATA_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "data"))
|
_DATA_DIR = paths.DATA_DIR
|
||||||
_KEY_PATH = os.environ.get("THERMOGRAPH_INDEXNOW_KEY_FILE") or os.path.join(_DATA_DIR, "indexnow_key.txt")
|
_KEY_PATH = os.environ.get("THERMOGRAPH_INDEXNOW_KEY_FILE") or os.path.join(_DATA_DIR, "indexnow_key.txt")
|
||||||
_STATE_PATH = os.environ.get("THERMOGRAPH_INDEXNOW_STATE_FILE") or os.path.join(_DATA_DIR, "indexnow_state.txt")
|
_STATE_PATH = os.environ.get("THERMOGRAPH_INDEXNOW_STATE_FILE") or os.path.join(_DATA_DIR, "indexnow_state.txt")
|
||||||
_ENDPOINT = "https://api.indexnow.org/indexnow" # a shared endpoint; it fans out to all participants
|
_ENDPOINT = "https://api.indexnow.org/indexnow" # a shared endpoint; it fans out to all participants
|
||||||
|
|
@ -98,7 +100,7 @@ def submit(urls, host: str, key_location: str, scheme: str = "https", timeout: f
|
||||||
def submit_all(site_base_url: str, **kw) -> dict:
|
def submit_all(site_base_url: str, **kw) -> dict:
|
||||||
"""Submit every indexable page. ``site_base_url`` is the public origin + base
|
"""Submit every indexable page. ``site_base_url`` is the public origin + base
|
||||||
path, e.g. ``https://thermograph.org`` (root) or ``https://host/thermograph``."""
|
path, e.g. ``https://thermograph.org`` (root) or ``https://host/thermograph``."""
|
||||||
import content # lazy: avoids a content ↔ indexnow import cycle
|
from web import content # lazy: avoids a content ↔ indexnow import cycle
|
||||||
site = site_base_url.rstrip("/")
|
site = site_base_url.rstrip("/")
|
||||||
parsed = urlparse(site)
|
parsed = urlparse(site)
|
||||||
urls = [site + path for path in content.public_paths()]
|
urls = [site + path for path in content.public_paths()]
|
||||||
|
|
@ -110,7 +112,7 @@ def url_signature() -> str:
|
||||||
"""A stable fingerprint of the indexable URL set — changes only when pages are
|
"""A stable fingerprint of the indexable URL set — changes only when pages are
|
||||||
added or removed (e.g. a new city), not on code-only deploys. Used to skip
|
added or removed (e.g. a new city), not on code-only deploys. Used to skip
|
||||||
resubmitting an unchanged set (see the --if-changed CLI flag)."""
|
resubmitting an unchanged set (see the --if-changed CLI flag)."""
|
||||||
import content
|
from web import content
|
||||||
return hashlib.sha1("\n".join(content.public_paths()).encode()).hexdigest()
|
return hashlib.sha1("\n".join(content.public_paths()).encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,10 @@ import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import climate
|
from data import climate
|
||||||
import grid
|
from data import grid
|
||||||
import store
|
from data import store
|
||||||
import views
|
from web import views
|
||||||
|
|
||||||
|
|
||||||
def migrate() -> int:
|
def migrate() -> int:
|
||||||
|
|
|
||||||
1
notifications/__init__.py
Normal file
1
notifications/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""notifications package."""
|
||||||
|
|
@ -16,10 +16,10 @@ from fastapi import Request, Response
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
import mailer
|
from notifications import mailer
|
||||||
import metrics
|
from core import metrics
|
||||||
from db import async_session_maker
|
from accounts.db import async_session_maker
|
||||||
from models import PendingDigest
|
from accounts.models import PendingDigest
|
||||||
|
|
||||||
# Deliberately permissive: real-world addresses defeat clever patterns, and the
|
# Deliberately permissive: real-world addresses defeat clever patterns, and the
|
||||||
# confirmation email is the actual validator. This only rejects obvious junk.
|
# confirmation email is the actual validator. This only rejects obvious junk.
|
||||||
|
|
@ -17,7 +17,7 @@ import time
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
import homepage
|
from web import homepage
|
||||||
|
|
||||||
# The webhook URL IS the credential — env only, never the repo. Unset => disabled.
|
# The webhook URL IS the credential — env only, never the repo. Unset => disabled.
|
||||||
WEBHOOK_URL = os.environ.get("THERMOGRAPH_DISCORD_WEBHOOK", "").strip()
|
WEBHOOK_URL = os.environ.get("THERMOGRAPH_DISCORD_WEBHOOK", "").strip()
|
||||||
|
|
@ -21,9 +21,9 @@ import os
|
||||||
from nacl.exceptions import BadSignatureError
|
from nacl.exceptions import BadSignatureError
|
||||||
from nacl.signing import VerifyKey
|
from nacl.signing import VerifyKey
|
||||||
|
|
||||||
import cities
|
from data import cities
|
||||||
import discord
|
from notifications import discord
|
||||||
import homepage
|
from web import homepage
|
||||||
|
|
||||||
# App's Ed25519 public key (Developer Portal -> General Information). Unset =>
|
# App's Ed25519 public key (Developer Portal -> General Information). Unset =>
|
||||||
# every request fails verification, which is the safe default for an unconfigured
|
# every request fails verification, which is the safe default for an unconfigured
|
||||||
|
|
@ -29,9 +29,9 @@ from pydantic import BaseModel
|
||||||
from sqlalchemy import update
|
from sqlalchemy import update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from db import get_async_session
|
from accounts.db import get_async_session
|
||||||
from models import User
|
from accounts.models import User
|
||||||
from users import SECRET, current_active_user
|
from accounts.users import SECRET, current_active_user
|
||||||
|
|
||||||
router = APIRouter(tags=["discord"])
|
router = APIRouter(tags=["discord"])
|
||||||
|
|
||||||
|
|
@ -29,16 +29,16 @@ import time
|
||||||
import polars as pl
|
import polars as pl
|
||||||
from sqlalchemy import delete, select
|
from sqlalchemy import delete, select
|
||||||
|
|
||||||
import climate
|
from data import climate
|
||||||
import discord
|
from notifications import discord
|
||||||
import grading
|
from data import grading
|
||||||
import grid
|
from data import grid
|
||||||
import homepage
|
from web import homepage
|
||||||
import metrics
|
from core import metrics
|
||||||
import push
|
from notifications import push
|
||||||
from db import sync_session_maker
|
from accounts.db import sync_session_maker
|
||||||
from models import AccessToken, Notification, PushSubscription, Subscription, User
|
from accounts.models import AccessToken, Notification, PushSubscription, Subscription, User
|
||||||
from views import OBS_COLS
|
from web.views import OBS_COLS
|
||||||
|
|
||||||
_BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/")
|
_BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/")
|
||||||
BASE = f"/{_BASE}" if _BASE else ""
|
BASE = f"/{_BASE}" if _BASE else ""
|
||||||
|
|
@ -25,7 +25,8 @@ import logging
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
import audit
|
from core import audit
|
||||||
|
import paths
|
||||||
|
|
||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
from cryptography.hazmat.primitives.asymmetric import ec
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
|
|
@ -33,7 +34,7 @@ from pywebpush import WebPushException, webpush
|
||||||
|
|
||||||
log = logging.getLogger("thermograph.push")
|
log = logging.getLogger("thermograph.push")
|
||||||
|
|
||||||
_DATA_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "data"))
|
_DATA_DIR = paths.DATA_DIR
|
||||||
_VAPID_PATH = os.environ.get("THERMOGRAPH_VAPID_FILE") or os.path.join(_DATA_DIR, "vapid.json")
|
_VAPID_PATH = os.environ.get("THERMOGRAPH_VAPID_FILE") or os.path.join(_DATA_DIR, "vapid.json")
|
||||||
# The VAPID "sub" claim — a contact the push service can reach about our traffic.
|
# The VAPID "sub" claim — a contact the push service can reach about our traffic.
|
||||||
_CONTACT = os.environ.get("THERMOGRAPH_VAPID_CONTACT", "mailto:admin@thermograph.app")
|
_CONTACT = os.environ.get("THERMOGRAPH_VAPID_CONTACT", "mailto:admin@thermograph.app")
|
||||||
42
paths.py
Normal file
42
paths.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
"""Canonical filesystem locations, resolved once from the repo root.
|
||||||
|
|
||||||
|
Every path the backend needs (the climate cache, the accounts DB, logs, the
|
||||||
|
Jinja templates, the frontend static dir, the bundled city data) is derived
|
||||||
|
here from the repository root rather than from each module's own ``__file__``.
|
||||||
|
That keeps the locations stable no matter how deep in the package tree a module
|
||||||
|
lives, so moving a module never silently re-points the cache or the accounts DB.
|
||||||
|
|
||||||
|
Callers that support an environment override (e.g. ``THERMOGRAPH_ACCOUNTS_DB``)
|
||||||
|
keep doing so; these are only the defaults.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
|
||||||
|
def _repo_root() -> str:
|
||||||
|
"""Walk up from this file until a directory holds both ``backend/`` and
|
||||||
|
``frontend/`` — the repository root, in every checkout (dev, prod, worktree)."""
|
||||||
|
d = _HERE
|
||||||
|
while True:
|
||||||
|
if os.path.isdir(os.path.join(d, "backend")) and os.path.isdir(os.path.join(d, "frontend")):
|
||||||
|
return d
|
||||||
|
parent = os.path.dirname(d)
|
||||||
|
if parent == d:
|
||||||
|
raise RuntimeError(
|
||||||
|
"cannot locate the repo root (no ancestor has sibling backend/ and frontend/)"
|
||||||
|
)
|
||||||
|
d = parent
|
||||||
|
|
||||||
|
|
||||||
|
REPO_DIR = _repo_root()
|
||||||
|
BACKEND_DIR = os.path.join(REPO_DIR, "backend")
|
||||||
|
DATA_DIR = os.path.join(REPO_DIR, "data")
|
||||||
|
LOGS_DIR = os.path.join(REPO_DIR, "logs")
|
||||||
|
FRONTEND_DIR = os.path.join(REPO_DIR, "frontend")
|
||||||
|
TEMPLATES_DIR = os.path.join(BACKEND_DIR, "templates")
|
||||||
|
|
||||||
|
# Bundled reference data shipped alongside the code (generated by gen_cities /
|
||||||
|
# gen_flavor), not runtime state — hence under backend/, not data/.
|
||||||
|
CITIES_JSON = os.path.join(BACKEND_DIR, "cities.json")
|
||||||
|
CITIES_FLAVOR_JSON = os.path.join(BACKEND_DIR, "cities_flavor.json")
|
||||||
|
|
@ -4,8 +4,8 @@ THERMOGRAPH_ACCOUNTS_DB at a temp file) so nothing is written into the repo."""
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
import app as appmod
|
from web import app as appmod
|
||||||
import db
|
from accounts import db
|
||||||
|
|
||||||
V2 = "/thermograph/api/v2"
|
V2 = "/thermograph/api/v2"
|
||||||
PW = "supersecret123"
|
PW = "supersecret123"
|
||||||
|
|
@ -120,7 +120,7 @@ def test_push_requires_auth():
|
||||||
|
|
||||||
|
|
||||||
def test_push_subscribe_upsert_test_unsubscribe(monkeypatch):
|
def test_push_subscribe_upsert_test_unsubscribe(monkeypatch):
|
||||||
import push
|
from notifications import push
|
||||||
sent = []
|
sent = []
|
||||||
monkeypatch.setattr(push, "send", lambda info, payload: sent.append(info["endpoint"]) or "ok")
|
monkeypatch.setattr(push, "send", lambda info, payload: sent.append(info["endpoint"]) or "ok")
|
||||||
c = _client()
|
c = _client()
|
||||||
|
|
@ -142,7 +142,7 @@ def test_push_subscribe_upsert_test_unsubscribe(monkeypatch):
|
||||||
|
|
||||||
|
|
||||||
def test_push_test_prunes_gone_endpoint(monkeypatch):
|
def test_push_test_prunes_gone_endpoint(monkeypatch):
|
||||||
import push
|
from notifications import push
|
||||||
monkeypatch.setattr(push, "send", lambda info, payload: "gone")
|
monkeypatch.setattr(push, "send", lambda info, payload: "gone")
|
||||||
c = _client()
|
c = _client()
|
||||||
_login(c, "push-gone@example.com")
|
_login(c, "push-gone@example.com")
|
||||||
|
|
@ -156,7 +156,7 @@ def test_push_test_prunes_gone_endpoint(monkeypatch):
|
||||||
def test_push_test_reports_failed_delivery(monkeypatch):
|
def test_push_test_reports_failed_delivery(monkeypatch):
|
||||||
# A rejected delivery (e.g. VAPID mismatch) must be reported, not silently dropped:
|
# A rejected delivery (e.g. VAPID mismatch) must be reported, not silently dropped:
|
||||||
# the endpoint keeps returning 202 but with sent:0 / failed:N so the client can tell.
|
# the endpoint keeps returning 202 but with sent:0 / failed:N so the client can tell.
|
||||||
import push
|
from notifications import push
|
||||||
monkeypatch.setattr(push, "send", lambda info, payload: "error")
|
monkeypatch.setattr(push, "send", lambda info, payload: "error")
|
||||||
c = _client()
|
c = _client()
|
||||||
_login(c, "push-fail@example.com")
|
_login(c, "push-fail@example.com")
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import pytest
|
||||||
BACKEND = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
BACKEND = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
sys.path.insert(0, BACKEND)
|
sys.path.insert(0, BACKEND)
|
||||||
|
|
||||||
import places # noqa: E402
|
from data import places # noqa: E402
|
||||||
|
|
||||||
# app.py calls places.start_loading() at import; pretend it already ran so no
|
# app.py calls places.start_loading() at import; pretend it already ran so no
|
||||||
# background GeoNames download starts. Tests build their own tiny index.
|
# background GeoNames download starts. Tests build their own tiny index.
|
||||||
|
|
@ -31,13 +31,13 @@ os.environ.setdefault("THERMOGRAPH_ENABLE_NOTIFIER", "0")
|
||||||
# Pin the IndexNow key so it isn't generated + written into the repo's data/ dir.
|
# Pin the IndexNow key so it isn't generated + written into the repo's data/ dir.
|
||||||
os.environ.setdefault("THERMOGRAPH_INDEXNOW_KEY", "test0000indexnow0000key000000000")
|
os.environ.setdefault("THERMOGRAPH_INDEXNOW_KEY", "test0000indexnow0000key000000000")
|
||||||
|
|
||||||
import store # noqa: E402
|
from data import store # noqa: E402
|
||||||
|
|
||||||
# Keep derived-store writes out of the repo's data/ folder. Individual store
|
# Keep derived-store writes out of the repo's data/ folder. Individual store
|
||||||
# tests re-point this per test; everything else shares one throwaway DB.
|
# tests re-point this per test; everything else shares one throwaway DB.
|
||||||
store.DB_PATH = os.path.join(_TMP, "store.sqlite")
|
store.DB_PATH = os.path.join(_TMP, "store.sqlite")
|
||||||
|
|
||||||
import audit # noqa: E402
|
from core import audit # noqa: E402
|
||||||
|
|
||||||
audit.AUDIT_DIR = os.path.join(_TMP, "logs", "audit")
|
audit.AUDIT_DIR = os.path.join(_TMP, "logs", "audit")
|
||||||
audit.ERROR_DIR = os.path.join(_TMP, "logs", "errors")
|
audit.ERROR_DIR = os.path.join(_TMP, "logs", "errors")
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""Unit tests for the in-process traffic counters (metrics.py)."""
|
"""Unit tests for the in-process traffic counters (metrics.py)."""
|
||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
import metrics
|
from core import metrics
|
||||||
|
|
||||||
|
|
||||||
def _fresh():
|
def _fresh():
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import fcntl
|
||||||
import importlib
|
import importlib
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import singleton
|
from core import singleton
|
||||||
|
|
||||||
|
|
||||||
def _fresh():
|
def _fresh():
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import datetime
|
||||||
import polars as pl
|
import polars as pl
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import climate
|
from data import climate
|
||||||
|
|
||||||
|
|
||||||
def _om_daily(n=3):
|
def _om_daily(n=3):
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import numpy as np
|
||||||
import polars as pl
|
import polars as pl
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import grading
|
from data import grading
|
||||||
|
|
||||||
|
|
||||||
# ---- empirical percentile ----------------------------------------------------
|
# ---- empirical percentile ----------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import math
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import grid
|
from data import grid
|
||||||
|
|
||||||
|
|
||||||
def test_snap_is_deterministic_and_contains_point():
|
def test_snap_is_deterministic_and_contains_point():
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import places
|
from data import places
|
||||||
|
|
||||||
|
|
||||||
def _entry(name, admin1, country, cc, lat, lon, pop):
|
def _entry(name, admin1, country, cc, lat, lon, pop):
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import numpy as np
|
||||||
import polars as pl
|
import polars as pl
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import scoring
|
from data import scoring
|
||||||
|
|
||||||
ALL_METRICS = ("tmax", "tmin", "feels", "humid", "wetbulb", "wind", "gust", "precip")
|
ALL_METRICS = ("tmax", "tmin", "feels", "humid", "wetbulb", "wind", "gust", "precip")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,12 @@ import pytest
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
import app as appmod
|
from web import app as appmod
|
||||||
import db
|
from accounts import db
|
||||||
import digest
|
from notifications import digest
|
||||||
import mailer
|
from notifications import mailer
|
||||||
from db import async_session_maker
|
from accounts.db import async_session_maker
|
||||||
from models import PendingDigest
|
from accounts.models import PendingDigest
|
||||||
|
|
||||||
B = "/thermograph"
|
B = "/thermograph"
|
||||||
|
|
||||||
|
|
@ -104,7 +104,7 @@ def test_signup_accepts_plain_form_post(client):
|
||||||
|
|
||||||
|
|
||||||
def test_signup_records_event(client):
|
def test_signup_records_event(client):
|
||||||
import metrics
|
from core import metrics
|
||||||
before = metrics.snapshot()["events"].get("home.digest_signup", {})
|
before = metrics.snapshot()["events"].get("home.digest_signup", {})
|
||||||
before_n = sum(sum(days.values()) for days in before.values())
|
before_n = sum(sum(days.values()) for days in before.values())
|
||||||
client.post(f"{B}/digest", json={"email": "counted@example.com"})
|
client.post(f"{B}/digest", json={"email": "counted@example.com"})
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ import time
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import discord
|
from notifications import discord
|
||||||
import notify
|
from notifications import notify
|
||||||
|
|
||||||
|
|
||||||
def _feed(ranked, generated_at=None, date=None):
|
def _feed(ranked, generated_at=None, date=None):
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,11 @@ import types
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
import app as appmod
|
from web import app as appmod
|
||||||
import db
|
from accounts import db
|
||||||
import discord
|
from notifications import discord
|
||||||
import discord_link as dl
|
from notifications import discord_link as dl
|
||||||
import notify
|
from notifications import notify
|
||||||
|
|
||||||
V2 = "/thermograph/api/v2"
|
V2 = "/thermograph/api/v2"
|
||||||
PW = "supersecret123"
|
PW = "supersecret123"
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@ import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from nacl.signing import SigningKey
|
from nacl.signing import SigningKey
|
||||||
|
|
||||||
import app as appmod
|
from web import app as appmod
|
||||||
import discord_interactions as di
|
from notifications import discord_interactions as di
|
||||||
import homepage
|
from web import homepage
|
||||||
|
|
||||||
B = "/thermograph"
|
B = "/thermograph"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@ mocked), unlink, and auth gating. Reuses the throwaway accounts DB from conftest
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
import app as appmod
|
from web import app as appmod
|
||||||
import db
|
from accounts import db
|
||||||
import discord_link as dl
|
from notifications import discord_link as dl
|
||||||
|
|
||||||
V2 = "/thermograph/api/v2"
|
V2 = "/thermograph/api/v2"
|
||||||
PW = "supersecret123"
|
PW = "supersecret123"
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,11 @@ import uuid
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import polars as pl
|
import polars as pl
|
||||||
|
|
||||||
import climate
|
from data import climate
|
||||||
import db
|
from accounts import db
|
||||||
import notify
|
from notifications import notify
|
||||||
import push
|
from notifications import push
|
||||||
from models import Notification, PushSubscription, Subscription, User
|
from accounts.models import Notification, PushSubscription, Subscription, User
|
||||||
from sqlalchemy import delete, func, select
|
from sqlalchemy import delete, func, select
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ would catch wiring regressions (e.g. a handler calling a deleted helper)."""
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
import app as appmod
|
from web import app as appmod
|
||||||
import climate
|
from data import climate
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -194,7 +194,7 @@ def test_pages_serve_with_origin_filled_in(client):
|
||||||
|
|
||||||
|
|
||||||
def test_cell_neighbors_flag_enqueues_warming(client, monkeypatch):
|
def test_cell_neighbors_flag_enqueues_warming(client, monkeypatch):
|
||||||
import app as appmod
|
from web import app as appmod
|
||||||
monkeypatch.setattr(appmod, "_WARM_SEEN", {})
|
monkeypatch.setattr(appmod, "_WARM_SEEN", {})
|
||||||
# No lifespan in TestClient without a context manager, so no worker drains
|
# No lifespan in TestClient without a context manager, so no worker drains
|
||||||
# the queue — enqueued cells just accumulate for inspection.
|
# the queue — enqueued cells just accumulate for inspection.
|
||||||
|
|
@ -210,9 +210,9 @@ def test_cell_neighbors_flag_enqueues_warming(client, monkeypatch):
|
||||||
|
|
||||||
|
|
||||||
def test_warm_cell_materializes_history_slices(client, tmp_store, monkeypatch, history):
|
def test_warm_cell_materializes_history_slices(client, tmp_store, monkeypatch, history):
|
||||||
import app as appmod
|
from web import app as appmod
|
||||||
import grid
|
from data import grid
|
||||||
import views
|
from web import views
|
||||||
cell = grid.snap(-33.87, 151.21)
|
cell = grid.snap(-33.87, 151.21)
|
||||||
appmod._warm_cell(cell)
|
appmod._warm_cell(cell)
|
||||||
token = views.history_token(history)
|
token = views.history_token(history)
|
||||||
|
|
@ -224,7 +224,7 @@ def test_warm_cell_materializes_history_slices(client, tmp_store, monkeypatch, h
|
||||||
|
|
||||||
|
|
||||||
def test_warm_cell_never_fetches_upstream(client, monkeypatch):
|
def test_warm_cell_never_fetches_upstream(client, monkeypatch):
|
||||||
import app as appmod
|
from web import app as appmod
|
||||||
def boom(cell):
|
def boom(cell):
|
||||||
raise AssertionError("warming must never fetch weather upstream")
|
raise AssertionError("warming must never fetch weather upstream")
|
||||||
monkeypatch.setattr(climate, "get_history", boom)
|
monkeypatch.setattr(climate, "get_history", boom)
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,10 @@ import re
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
import app as appmod
|
from web import app as appmod
|
||||||
import cities
|
from data import cities
|
||||||
import climate
|
from data import climate
|
||||||
import grading
|
from data import grading
|
||||||
|
|
||||||
# BASE defaults to /thermograph in tests (THERMOGRAPH_BASE unset).
|
# BASE defaults to /thermograph in tests (THERMOGRAPH_BASE unset).
|
||||||
B = "/thermograph"
|
B = "/thermograph"
|
||||||
|
|
@ -84,7 +84,7 @@ def test_city_404(client):
|
||||||
|
|
||||||
|
|
||||||
def test_curated_events_have_valid_slugs():
|
def test_curated_events_have_valid_slugs():
|
||||||
import city_events
|
from data import city_events
|
||||||
slugs = set(cities.all_slugs())
|
slugs = set(cities.all_slugs())
|
||||||
assert len(city_events.EVENTS) >= 20
|
assert len(city_events.EVENTS) >= 20
|
||||||
assert all(s in slugs for s in city_events.EVENTS), \
|
assert all(s in slugs for s in city_events.EVENTS), \
|
||||||
|
|
@ -344,7 +344,7 @@ def test_wind_and_humidity_render_per_unit_system():
|
||||||
# The synthetic history carries only tmax/tmin/precip, so wind and humidity
|
# The synthetic history carries only tmax/tmin/precip, so wind and humidity
|
||||||
# never reach a rendered page in tests — exercise the renderers directly
|
# never reach a rendered page in tests — exercise the renderers directly
|
||||||
# rather than assert on a page that structurally cannot show them.
|
# rather than assert on a page that structurally cannot show them.
|
||||||
import content
|
from web import content
|
||||||
with content._unit_scope("F"):
|
with content._unit_scope("F"):
|
||||||
assert content._wind_text(10) == "10 mph"
|
assert content._wind_text(10) == "10 mph"
|
||||||
assert '<span class="wind" data-wind-mph="10.0">10 mph</span>' == content._wind(10)
|
assert '<span class="wind" data-wind-mph="10.0">10 mph</span>' == content._wind(10)
|
||||||
|
|
@ -362,7 +362,7 @@ def test_wind_and_humidity_render_per_unit_system():
|
||||||
def test_precip_precision_differs_by_unit():
|
def test_precip_precision_differs_by_unit():
|
||||||
# Rainfall is reported in whole millimetres; a tenth of a mm is below what
|
# Rainfall is reported in whole millimetres; a tenth of a mm is below what
|
||||||
# the source resolves. Inches keep two decimals.
|
# the source resolves. Inches keep two decimals.
|
||||||
import content
|
from web import content
|
||||||
with content._unit_scope("F"):
|
with content._unit_scope("F"):
|
||||||
assert content._precip_text(0.04) == "0.04 in"
|
assert content._precip_text(0.04) == "0.04 in"
|
||||||
with content._unit_scope("C"):
|
with content._unit_scope("C"):
|
||||||
|
|
@ -373,7 +373,7 @@ def test_precip_precision_differs_by_unit():
|
||||||
def test_measure_conversion_constants_match_the_frontend():
|
def test_measure_conversion_constants_match_the_frontend():
|
||||||
# Same drift risk as the country list: two hand-maintained copies.
|
# Same drift risk as the country list: two hand-maintained copies.
|
||||||
import os
|
import os
|
||||||
import content
|
from web import content
|
||||||
units_js = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
units_js = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||||
os.pardir, "frontend", "units.js")
|
os.pardir, "frontend", "units.js")
|
||||||
with open(units_js, encoding="utf-8") as f:
|
with open(units_js, encoding="utf-8") as f:
|
||||||
|
|
@ -412,7 +412,7 @@ def test_f_country_list_matches_the_frontend():
|
||||||
# drifting apart, and drift means the server and the client disagree about
|
# drifting apart, and drift means the server and the client disagree about
|
||||||
# what a first-time visitor should see.
|
# what a first-time visitor should see.
|
||||||
import os
|
import os
|
||||||
import content
|
from web import content
|
||||||
units_js = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
units_js = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||||
os.pardir, "frontend", "units.js")
|
os.pardir, "frontend", "units.js")
|
||||||
with open(units_js, encoding="utf-8") as f:
|
with open(units_js, encoding="utf-8") as f:
|
||||||
|
|
@ -425,7 +425,7 @@ def test_celsius_rounding_matches_js():
|
||||||
# Python's round() goes to even, JS's Math.round goes up. Where they disagree
|
# Python's round() goes to even, JS's Math.round goes up. Where they disagree
|
||||||
# the server prints one number and climate.js repaints another — a flicker for
|
# the server prints one number and climate.js repaints another — a flicker for
|
||||||
# the visitor whose unit already matched.
|
# the visitor whose unit already matched.
|
||||||
import content
|
from web import content
|
||||||
assert content._round_half_up(0.5) == 1 # round(0.5) would give 0
|
assert content._round_half_up(0.5) == 1 # round(0.5) would give 0
|
||||||
assert content._round_half_up(1.5) == 2
|
assert content._round_half_up(1.5) == 2
|
||||||
assert content._round_half_up(2.5) == 3 # round(2.5) would give 2
|
assert content._round_half_up(2.5) == 3 # round(2.5) would give 2
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,11 @@ import time
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
import app as appmod
|
from web import app as appmod
|
||||||
import cities
|
from data import cities
|
||||||
import climate
|
from data import climate
|
||||||
import content
|
from web import content
|
||||||
import homepage
|
from web import homepage
|
||||||
|
|
||||||
B = "/thermograph"
|
B = "/thermograph"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ import sys
|
||||||
import polars as pl
|
import polars as pl
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import climate
|
from data import climate
|
||||||
import views
|
from web import views
|
||||||
|
|
||||||
CELL = {"id": "1642_-4223", "center_lat": 47.6087, "center_lon": -122.29377}
|
CELL = {"id": "1642_-4223", "center_lat": 47.6087, "center_lon": -122.29377}
|
||||||
|
|
||||||
|
|
@ -17,10 +17,10 @@ CELL = {"id": "1642_-4223", "center_lat": 47.6087, "center_lon": -122.29377}
|
||||||
def test_views_and_migrate_import_without_the_web_stack():
|
def test_views_and_migrate_import_without_the_web_stack():
|
||||||
"""migrate.py must stay runnable offline: importing the payload layer may
|
"""migrate.py must stay runnable offline: importing the payload layer may
|
||||||
not construct the FastAPI app or start the places-index download."""
|
not construct the FastAPI app or start the places-index download."""
|
||||||
code = ("import sys; import views, migrate; "
|
code = ("import sys; from web import views; import migrate; "
|
||||||
"assert 'fastapi' not in sys.modules, 'views/migrate pulled in FastAPI'; "
|
"assert 'fastapi' not in sys.modules, 'views/migrate pulled in FastAPI'; "
|
||||||
"assert 'app' not in sys.modules, 'views/migrate imported the web app'; "
|
"assert 'web.app' not in sys.modules, 'views/migrate imported the web app'; "
|
||||||
"import places; assert places._load_started is False")
|
"from data import places; assert places._load_started is False")
|
||||||
backend = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
backend = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
subprocess.run([sys.executable, "-c", code], cwd=backend, check=True)
|
subprocess.run([sys.executable, "-c", code], cwd=backend, check=True)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,10 @@ so this is an optimization, not a hard dependency.
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import cities
|
from data import cities
|
||||||
import climate
|
from data import climate
|
||||||
import grid
|
from data import grid
|
||||||
import homepage
|
from web import homepage
|
||||||
|
|
||||||
|
|
||||||
def main(limit: int | None = None, pace: float = 2.0) -> None:
|
def main(limit: int | None = None, pace: float = 2.0) -> None:
|
||||||
|
|
|
||||||
1
web/__init__.py
Normal file
1
web/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""web package."""
|
||||||
783
web/app.py
Normal file
783
web/app.py
Normal file
|
|
@ -0,0 +1,783 @@
|
||||||
|
"""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
|
||||||
|
|
||||||
|
from accounts import api_accounts
|
||||||
|
from core import audit
|
||||||
|
from data import climate
|
||||||
|
from web import content
|
||||||
|
from notifications import digest
|
||||||
|
from notifications import discord_interactions as discord_interactions_mod
|
||||||
|
from notifications import discord_link
|
||||||
|
from accounts import db
|
||||||
|
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
|
||||||
|
from web import views
|
||||||
|
from web.schemas import UserCreate, UserRead, UserUpdate
|
||||||
|
|
||||||
|
FRONTEND_DIR = paths.FRONTEND_DIR
|
||||||
|
|
||||||
|
# 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}/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.
|
||||||
|
# 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_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):
|
||||||
|
payload = views.build_score(cell, history, 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, "score", cell,
|
||||||
|
views.score_key(), views.history_token(history), 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("/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")
|
||||||
|
|
||||||
|
|
||||||
|
# --- 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()
|
||||||
|
status, payload = discord_interactions_mod.handle(
|
||||||
|
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),
|
||||||
|
# /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")
|
||||||
|
# Discord account linking (OAuth2, authed).
|
||||||
|
app.include_router(discord_link.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}/score", _page("score.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")
|
||||||
|
|
@ -20,18 +20,19 @@ from fastapi.responses import PlainTextResponse
|
||||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
|
|
||||||
import cities
|
from data import cities
|
||||||
import city_events
|
from data import city_events
|
||||||
import climate
|
from data import climate
|
||||||
import grading
|
from data import grading
|
||||||
import grid
|
from data import grid
|
||||||
import homepage
|
from web import homepage
|
||||||
from views import OBS_COLS
|
import paths
|
||||||
|
from web.views import OBS_COLS
|
||||||
|
|
||||||
_BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/")
|
_BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/")
|
||||||
BASE = f"/{_BASE}" if _BASE else ""
|
BASE = f"/{_BASE}" if _BASE else ""
|
||||||
|
|
||||||
TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "templates")
|
TEMPLATES_DIR = paths.TEMPLATES_DIR
|
||||||
_env = Environment(
|
_env = Environment(
|
||||||
loader=FileSystemLoader(TEMPLATES_DIR),
|
loader=FileSystemLoader(TEMPLATES_DIR),
|
||||||
autoescape=select_autoescape(["html", "xml", "j2"]),
|
autoescape=select_autoescape(["html", "xml", "j2"]),
|
||||||
|
|
@ -359,8 +360,8 @@ def _content_lastmod() -> str:
|
||||||
city list and this module (both change on a content/code rebuild, and the
|
city list and this module (both change on a content/code rebuild, and the
|
||||||
process restarts on deploy). Honest and stable, unlike a per-request today()
|
process restarts on deploy). Honest and stable, unlike a per-request today()
|
||||||
that churns every fetch and trains crawlers to ignore lastmod entirely."""
|
that churns every fetch and trains crawlers to ignore lastmod entirely."""
|
||||||
paths = [os.path.join(os.path.dirname(__file__), "cities.json"), __file__]
|
srcs = [paths.CITIES_JSON, __file__]
|
||||||
mtimes = [os.path.getmtime(p) for p in paths if os.path.exists(p)]
|
mtimes = [os.path.getmtime(p) for p in srcs if os.path.exists(p)]
|
||||||
day = datetime.date.fromtimestamp(max(mtimes)) if mtimes else datetime.date.today()
|
day = datetime.date.fromtimestamp(max(mtimes)) if mtimes else datetime.date.today()
|
||||||
return day.isoformat()
|
return day.isoformat()
|
||||||
|
|
||||||
|
|
@ -27,15 +27,16 @@ import time
|
||||||
|
|
||||||
import polars as pl
|
import polars as pl
|
||||||
|
|
||||||
import cities
|
from data import cities
|
||||||
import climate
|
from data import climate
|
||||||
import grading
|
from data import grading
|
||||||
import grid
|
from data import grid
|
||||||
from views import OBS_COLS
|
import paths
|
||||||
|
from web.views import OBS_COLS
|
||||||
|
|
||||||
# data/ is the only writable path under the hardened systemd unit
|
# data/ is the only writable path under the hardened systemd unit
|
||||||
# (ReadWritePaths=/opt/thermograph/data …), so the feed lives there.
|
# (ReadWritePaths=/opt/thermograph/data …), so the feed lives there.
|
||||||
FEED_PATH = os.path.join(os.path.dirname(__file__), os.pardir, "data", "homepage.json")
|
FEED_PATH = os.path.join(paths.DATA_DIR, "homepage.json")
|
||||||
|
|
||||||
# How many cards the "Unusual right now" strip can show.
|
# How many cards the "Unusual right now" strip can show.
|
||||||
RANK_LIMIT = 12
|
RANK_LIMIT = 12
|
||||||
|
|
@ -10,7 +10,7 @@ from typing import Literal
|
||||||
from fastapi_users import schemas
|
from fastapi_users import schemas
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
import grading
|
from data import grading
|
||||||
|
|
||||||
# Canonical metric keys a subscription may watch (kept in lockstep with grading).
|
# Canonical metric keys a subscription may watch (kept in lockstep with grading).
|
||||||
ALLOWED_METRICS = tuple(grading.CLIMO_METRICS)
|
ALLOWED_METRICS = tuple(grading.CLIMO_METRICS)
|
||||||
|
|
@ -13,9 +13,9 @@ import time
|
||||||
|
|
||||||
import polars as pl
|
import polars as pl
|
||||||
|
|
||||||
import climate
|
from data import climate
|
||||||
import grading
|
from data import grading
|
||||||
import scoring
|
from data import scoring
|
||||||
|
|
||||||
# Observed values pulled from a daily record row for grading. Includes the
|
# Observed values pulled from a daily record row for grading. Includes the
|
||||||
# temperature-scale metrics (tmax/tmin/feels/wind/gust) plus precip; a column may
|
# temperature-scale metrics (tmax/tmin/feels/wind/gust) plus precip; a column may
|
||||||
Loading…
Reference in a new issue