"""Portfolio tracker entrypoint. Run locally: APP_CONFIG_FILE=./config.yaml APP_DATABASE__PATH=./stocktake.db \ uvicorn app.main:app --reload Access control has two independent layers, on purpose: * `require_login` (middleware) — nothing but the probes, the static files and the login/setup pages is reachable logged out. * `tenancy` — every query for personal data is filtered to the session's user, so even a route that forgets to filter cannot serve another portfolio. """ from __future__ import annotations import asyncio import datetime as dt import json import logging import math import re import threading import urllib.parse from contextlib import asynccontextmanager, contextmanager from decimal import Decimal from http import HTTPStatus from pathlib import Path from typing import Annotated from urllib.parse import quote_plus from zoneinfo import ZoneInfo from fastapi import Form, HTTPException, Request from fastapi.encoders import jsonable_encoder from fastapi.exception_handlers import http_exception_handler from fastapi.exceptions import RequestValidationError from fastapi.responses import ( HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse, Response, ) from pydantic import BaseModel, Field from sqlalchemy import func, select, text, update from sqlalchemy.orm import Session as DbSession from sqlalchemy.orm import object_session from starlette.exceptions import HTTPException as StarletteHTTPException from appcore import ( create_app, ensure_utc, load_config, oidc, ) from appcore.config import DatabaseSettings from . import ( auth, branding, brokerage, brokerdesign, calendarview, chart_templates, charts_build, clock, columns, configfile, cpi, exports, features, federation, fields, fyreport, invites, lifecycle, logbuffer, maintenance, markettime, memory, metrics, money, moves, navigation, passkeys, plans, pricefeed, providers, queries, setupwizard, simulate, sorting, statements, submissions, tenancy, textfield, theming, twofactor, wording, ) from . import ( db as database, ) from .api import router as api_router from .imports_web import router as imports_router from .models import ( ACTION_KINDS, ASSET_CLASSES, MARKET_OPEN, MEMBER_ROLES, NAV_STYLES, ROLE_BLURBS, ROLE_LABELS, ROW_ID_MAX, THEMES, TIME_ZONES_SHOWN, WRITE_ROLES, ApiKey, CorporateAction, Dividend, ExternalIdentity, HoldingPref, Instrument, InvestmentPlan, InvestmentPlanEntry, PlannedPurchase, Portfolio, PortfolioInvite, PortfolioMember, Price, RowId, SavedChart, Trade, User, UserSession, currency_problem, exchange_problem, row_id, ticker_problem, ) from .settings import PortfolioSettings log = logging.getLogger(__name__) APP_DIR = Path(__file__).parent # Where the published documentation lives. ONE constant rather than a URL typed # into each template that links out, because a search-and-replace across # templates is how one gets missed. DOCS_URL = "https://jarrodruggiero.github.io/stocktake" settings = load_config(PortfolioSettings) # Before anything can ask what day it is: a container runs in UTC, and every # date decision in this app belongs to the portfolio's own zone. clock.configure(settings.timezone) # Root-logger setup so app INFO lines (price feed runs, warnings) reach the pod # logs — uvicorn only configures its own loggers, leaving ours invisible. logging.basicConfig( level=str(settings.log_level).upper(), format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) # Keep the last few hundred WARNING-and-above lines in memory for the Settings # page's console. Installed here rather than in the lifespan so it is # collecting before the first request — startup is exactly when the # interesting failures happen. logbuffer.install() # Where the database comes from, and whether there is one at all. Connecting is # deliberately conditional so a fresh install can serve the wizard — # decisions.md #41. database.set_migrations(str(APP_DIR / "migrations")) database.start(settings) def SessionLocal(): """A database session. A function rather than the sessionmaker itself, because the factory behind it is built at connect time and can be replaced by the wizard. Every call site keeps working unchanged; the tests still repoint this name directly. """ return database.session() # Reachable without a session: probes, the login/setup flow, and the assets # those pages need. `/login/code`, `/session/status` and `/metrics` are public # to the MIDDLEWARE only, and each guards itself — decisions.md #29. PUBLIC_PATHS = {"/healthz", "/readyz", "/login", "/login/code", "/login/recover", # Signing in with a passkey happens before there is a session, # so both halves of the ceremony are public. Each verifies the # pre-auth CSRF token, and the second only trusts a signature. "/login/passkey", "/login/passkey/options", # The provider redirects the browser back here before there is # a session; `state` is what makes the callback trustworthy. "/login/oidc", "/login/oidc/callback", # The provider POSTs here with no cookie and no CSRF token. # The signature on the `logout_token` is the authentication — # see `oidc_backchannel_logout`, and decisions.md #127. "/oidc/backchannel-logout", "/setup", "/session/status", "/metrics"} # Two prefixes the login middleware waves through, each of which guards # itself. **Both failure modes are silent publication of an endpoint:** # * a route under /api/ that does not open with `auth.api_session(...)` # * a route under /setup/ that does not open with `_wizard_step()` # `/invite/` is public because the person opening one has no account yet. The # token in the URL is the only authorisation, and every route under it checks # it — decisions.md #116. PUBLIC_PREFIXES = ("/static/", "/api/", "/setup/", "/invite/") # The subset that must answer with NO database at all. Everything else — /login # included — needs one to say anything useful, and gets sent to the wizard # instead of a 500 from its first query. NO_DATABASE_PATHS = {"/healthz", "/readyz", "/setup", "/metrics"} NO_DATABASE_PREFIXES = ("/static/", "/setup/") def _db_ready() -> bool: """Readiness, which is not the same question as "is there a database". An unconfigured install is *ready*: it can serve the wizard, which is the only thing there is to serve and the only way to make it into anything more. Reporting not-ready would be a deadlock — Kubernetes would keep traffic away from the page that fixes it. """ if not database.is_ready(): return True try: with SessionLocal() as s: s.execute(text("SELECT 1")) return True except Exception: return False # --------------------------------------------------------------------------- # # Price feed # --------------------------------------------------------------------------- # # Last feed outcome, shown on the dashboard so a stalled feed is visible # instead of silently serving stale prices. In-memory is fine: one process. feed_status: dict = {"running": False, "finished": None, "ok": None, "error": None, "counts": None, "degraded": {}, "quoted": None, "quotes": 0} _feed_lock = threading.Lock() def _run_feed() -> bool: if not _feed_lock.acquire(blocking=False): return True # already in flight (manual refresh raced the schedule) feed_status["running"] = True try: # Market data is shared and the FX repair pass spans every user's # trades, so this is the one place tenancy is deliberately bypassed. with tenancy.unscoped_session(SessionLocal) as s: counts = pricefeed.run_feed(s, settings) # Only the symbols a fallback served, or that nobody could serve. The # normal case is silence; a list here means Yahoo let us down for # something, which is exactly what goes unnoticed otherwise. feed_status.update( ok=True, error=None, counts=counts, degraded={ key: summary for key, summary in pricefeed.last_run_sources.items() if "failed" in summary or "no rows" in summary }, ) return True except Exception as exc: log.exception("price feed run failed") feed_status.update(ok=False, error=f"{type(exc).__name__}: {exc}", counts=None) return False finally: # In `finally` so a run is counted whichever branch returned. Reads the # outcome back off `feed_status` rather than tracking a second flag, # because both branches have just written it and two sources of the # same truth is how they drift. metrics.record_feed_run(ok=bool(feed_status["ok"])) feed_status.update( running=False, finished=dt.datetime.now(ZoneInfo(settings.price_feed.timezone)), ) _feed_lock.release() _quote_lock = threading.Lock() def _run_quotes() -> int: """One batched live-price refresh. Never raises: a quote is a nicety.""" if not _quote_lock.acquire(blocking=False): return 0 try: with tenancy.unscoped_session(SessionLocal) as s: written = pricefeed.refresh_quotes(s, settings) if written: # The panel says "Last refreshed