Ctrl K
ComunidadGuías…

app/setupwizard.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""First-run setup: the steps, what each needs, and what it writes.

  1. welcome     — what was detected: database, config file, restartability.
  2. database    — only when nothing configured one.
  3. account     — the security gate. Everything after runs in a session.
  4. recovery    — codes shown once, before 2FA (decisions.md #66).
  5. 2fa         — only when the account step asked for it.
  6. portfolio   — first portfolio, market data, timezone.
  7. environment — trusted proxies and the external URL. All optional.
  8. finish      — write config.yaml; restart if anything needs one.

Each step is gated on what the one before established: 3 cannot run before 2,
and 4-6 cannot run before 3. The draft lives in memory and nothing is written
until it has been shown to work — decisions.md #65.
"""

from __future__ import annotations

import datetime as dt
import ipaddress
import logging
import os
import re
from dataclasses import dataclass, field
from urllib.parse import urlsplit
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError, available_timezones

from appcore.config import DatabaseSettings

from .db import ENV_PASSWORD

log = logging.getLogger(__name__)

# The steps, in order. The wizard's progress bar and the "which is next"
# question both read this rather than hard-coding an order in two places.
STEPS: tuple[tuple[str, str], ...] = (
    ("welcome", "Welcome"),
    ("database", "Database"),
    ("account", "Your account"),
    ("recovery", "Recovery codes"),
    # Offered only when the account step asked for it, so the rail leaves it
    # out and renumbers otherwise. AFTER the codes on purpose — decisions.md
    # #66 and #49.
    ("2fa", "Two-factor"),
    ("portfolio", "First portfolio"),
    ("features", "Optional features"),
    ("environment", "Environment"),
    ("finish", "Finish"),
)
STEP_KEYS = [key for key, _ in STEPS]


# --------------------------------------------------------------------------- #
# The draft
# --------------------------------------------------------------------------- #

@dataclass
class Draft:
    """Everything the wizard has been told but not yet written down."""

    started: dt.datetime = field(default_factory=lambda: dt.datetime.now(dt.timezone.utc))
    # Step 2 — set only when the wizard chose the database itself. When the
    # deployment configured it, this stays None and nothing is written for it.
    database: DatabaseSettings | None = None
    # Step 4 — the codes as issued, so returning to the step re-shows THEM
    # rather than minting a replacement set. Regenerating on a revisit is
    # silent invalidation: somebody writes them down, presses back, and the
    # list in their hand is dead with nothing on screen saying so.
    recovery_codes: list[str] = field(default_factory=list)
    # Step 3 — whether the account step asked to enrol a second factor. The
    # step is left out of the rail entirely when it did not.
    want_2fa: bool = False
    # Step 5
    portfolio_name: str = ""
    price_feed: bool = True
    timezone: str | None = None
    # Step 6 — which optional features are on. Keyed by `features.Feature.key`;
    # absent means the default, which is on.
    features: dict[str, bool] = field(default_factory=dict)
    # Step 7 — all optional; a blank is "not set", not "set to empty".
    trusted_proxies: list[str] = field(default_factory=list)
    external_url: str | None = None
    # Progress
    done: set[str] = field(default_factory=set)

    def completed(self, step: str) -> None:
        self.done.add(step)

    def has(self, step: str) -> bool:
        return step in self.done


_draft: Draft | None = None


def draft() -> Draft:
    """The wizard's working state, created on first use."""
    global _draft
    if _draft is None:
        _draft = Draft()
    return _draft


def discard() -> None:
    """Forget the draft — the wizard finished, or a test wants a clean one."""
    global _draft
    _draft = None


def in_progress() -> bool:
    return _draft is not None


# --------------------------------------------------------------------------- #
# Which step is next
# --------------------------------------------------------------------------- #

def skipped() -> set[str]:
    """Steps this run does not offer, so the rail and the routing cannot
    disagree about what exists — which is how the loop in decisions.md #109
    was built out of three individually reasonable answers.
    """
    return set() if draft().want_2fa else {"2fa"}


def next_step(*, database_configured: bool, account_exists: bool) -> str:
    """The first step that still has something to do.

    Derived rather than stored, so a reload or a back button lands somewhere
    sensible instead of resuming a position the state no longer supports.
    """
    current = draft()
    if not current.has("welcome"):
        return "welcome"
    if not database_configured:
        return "database"
    if not account_exists:
        return "account"
    # Derived from STEPS, not a second list. The hardcoded one still carried
    # "2fa" after that stopped being a step (decisions.md #49), so this could
    # name a step with no page — harmless while nothing turned the answer into
    # a URL, and a 404 the moment something did.
    skip = skipped()
    for step in STEP_KEYS[STEP_KEYS.index("account") + 1:]:
        if step not in skip and not current.has(step):
            return step
    return STEP_KEYS[-1]


def progress(active: str, *, skip: set[str] | None = None) -> list[dict]:
    """The step list as the template renders it: name, state, number.

    A step that does not apply is left out and the rest are numbered as shown;
    `done` beats `skipped`, which is the bug that was hiding here —
    decisions.md #60.
    """
    skip = skip or set()
    current = draft()
    out = []
    for index, (key, label) in enumerate(STEPS):
        # Completed explicitly, or simply behind us — but "behind us" does not
        # apply to a step that was never offered. Without that second clause a
        # skipped step earlier in the list counts as done and can never be
        # left out, which is the whole point of `skip`.
        done = current.has(key) or (
            STEP_KEYS.index(active) > index and key not in skip)
        if key == active:
            state = "active"
        elif done:
            state = "done"
        elif key in skip:
            continue        # not applicable here: leave it out entirely
        else:
            state = "todo"
        out.append({"key": key, "label": label, "state": state,
                    "number": len(out) + 1})
    return out


# --------------------------------------------------------------------------- #
# Step 2 — the database form
# --------------------------------------------------------------------------- #

def sqlite_settings(path: str) -> DatabaseSettings:
    return DatabaseSettings(type="sqlite", path=path.strip() or "/data/stocktake.db")


def postgres_settings(*, host: str, port: str, name: str, user: str,
                      password: str) -> DatabaseSettings:
    """Build Postgres settings from the form, raising ValueError with something
    worth reading rather than a pydantic dump."""
    host = host.strip()
    name = name.strip()
    user = user.strip()
    if not host:
        raise ValueError("Enter the database server's hostname or address.")
    if not name:
        raise ValueError("Enter the name of the database to use.")
    if not user:
        raise ValueError("Enter the username to connect as.")
    try:
        port_number = int(str(port).strip() or "5432")
    except ValueError:
        raise ValueError("The port must be a whole number.") from None
    if not 1 <= port_number <= 65535:
        raise ValueError("The port must be between 1 and 65535.")
    # The environment's password wins at startup, so it is the one to test and
    # to save with. `database_config` keeps it out of the file.
    password = os.environ.get(ENV_PASSWORD) or password
    return DatabaseSettings(type="postgres", host=host, port=port_number,
                            name=name, user=user, password=password)


def database_config(db: DatabaseSettings) -> dict:
    """The `database:` block to write for a chosen database.

    The password is included because there is nowhere else for it to go on a
    first run, and the block is only written when the wizard chose the database.

    A deployment that would rather keep the password in a Secret sets
    `APP_DATABASE__PASSWORD`, which wins at runtime because environment beats
    the config file. Then it is the password the step tests and saves with,
    whatever the field holds, and the block is written without one.
    """
    if db.type == "sqlite":
        return {"type": "sqlite", "path": db.path}
    block = {"type": "postgres", "host": db.host, "port": db.port,
             "name": db.name, "user": db.user}
    if not os.environ.get(ENV_PASSWORD):
        block["password"] = db.password
    return block


# --------------------------------------------------------------------------- #
# Step 5 — timezone
# --------------------------------------------------------------------------- #

def timezones() -> list[str]:
    """Every IANA zone this machine knows, sorted for a searchable dropdown."""
    return sorted(available_timezones())


def timezone_problem(name: str) -> str | None:
    name = (name or "").strip()
    if not name:
        return "Choose a timezone."
    try:
        ZoneInfo(name)
    except (ZoneInfoNotFoundError, ValueError, OSError):
        return (f"{name!r} is not a timezone this system knows. Pick one from "
                f"the list, or use an IANA name like Australia/Melbourne.")
    return None


# --------------------------------------------------------------------------- #
# Step 6 — environment
# --------------------------------------------------------------------------- #

def parse_proxies(raw: str) -> list[str]:
    """Split a textarea of addresses and CIDR ranges, validating each.

    Validated rather than trusted because the consequence of a typo is silent:
    an unparseable entry would simply never match, and the app would go on
    reading `X-Forwarded-For` from nobody while looking configured.
    """
    entries = [part for part in re.split(r"[,\s]+", raw or "") if part]
    out: list[str] = []
    for entry in entries:
        try:
            ipaddress.ip_network(entry, strict=False)
        except ValueError:
            raise ValueError(
                f"{entry!r} is not an IP address or CIDR range. Use something "
                f"like 10.1.0.0/16 or 172.18.0.5."
            ) from None
        out.append(entry)
    return out


@dataclass(frozen=True)
class ExternalUrl:
    """A URL the app will be reached at, and what follows from it."""

    url: str
    scheme: str
    host: str
    port: int | None

    @property
    def secure(self) -> bool:
        return self.scheme == "https"

    @property
    def display(self) -> str:
        return self.url


def parse_external_url(raw: str | None) -> ExternalUrl | None:
    """The address people will type, checked for the things that break silently.

    Only http and https: the app is reached over one of those, and accepting
    anything else would produce a redirect at the end of the wizard that goes
    nowhere.
    """
    raw = (raw or "").strip().rstrip("/")
    if not raw:
        return None
    if "://" not in raw:
        raise ValueError("Include the scheme, e.g. https://portfolio.example.com")
    parts = urlsplit(raw)
    if parts.scheme not in ("http", "https"):
        raise ValueError("The URL must start with http:// or https://")
    if not parts.hostname:
        raise ValueError("That URL has no hostname.")
    if parts.path:
        raise ValueError(
            "Use the base URL only — this app is served from the root of a "
            "hostname, not from a path underneath one."
        )
    return ExternalUrl(url=raw, scheme=parts.scheme, host=parts.hostname,
                       port=parts.port)


def tls_note(url: ExternalUrl | None) -> str:
    """What an HTTPS URL implies, said before it is chosen rather than after.

    The app never terminates TLS. Saying so here is not pedantry: somebody who
    expects it to will put https:// in this box, get a secure cookie, reach the
    app over plain HTTP anyway, and be unable to sign in — with nothing on
    screen connecting the two.
    """
    if url is None or not url.secure:
        return ""
    return (
        "This app does not terminate TLS itself — a reverse proxy (Traefik, "
        "Caddy, nginx, or your ingress) does, and forwards to this container "
        "over plain HTTP. Setting an https:// URL here marks the session cookie "
        "secure, which means the browser will only send it over HTTPS: reach "
        "the app directly on its HTTP port after this and you will not be able "
        "to sign in."
    )


# --------------------------------------------------------------------------- #
# Step 7 — what gets written
# --------------------------------------------------------------------------- #

def config_values(current: Draft) -> dict:
    """The config.yaml the wizard produces, as a nested dict.

    Only what was actually chosen. Everything omitted keeps its documented
    default, which is why the shipped file is almost entirely comments — a
    config listing every value would make every default a decision somebody has
    to maintain.
    """
    values: dict = {}
    if current.database is not None:
        values["database"] = database_config(current.database)
    if current.timezone:
        values["timezone"] = current.timezone
    values["price_feed"] = {"enabled": current.price_feed}
    auth: dict = {}
    if current.trusted_proxies:
        auth["trusted_proxies"] = list(current.trusted_proxies)
    url = parse_external_url(current.external_url)
    if url is not None and url.secure:
        auth["cookie_secure"] = True
    if auth:
        values["auth"] = auth
    # Only what was switched OFF. Writing `dca_schedule: true` would put a line
    # in the file for a default nobody changed, and the shipped config is
    # almost all comments precisely so that every value in it is a decision.
    off = {key: False for key, on in current.features.items() if not on}
    if off:
        values["features"] = off
    return values


def needs_restart(current: Draft) -> list[str]:
    """Which of the chosen values will not take effect until the process
    restarts. Named individually, because "some settings need a restart" tells
    nobody whether the thing they came for is one of them.
    """
    pending = []
    if current.database is not None:
        # The connection is live already — the config file is what makes it
        # survive, and that is the part a restart proves.
        pending.append("the database connection (already in use; the restart "
                       "confirms it is written down correctly)")
    if not current.price_feed:
        pending.append("turning the market-data feed off")
    if current.trusted_proxies:
        pending.append("trusted proxies")
    url = parse_external_url(current.external_url)
    if url is not None and url.secure:
        pending.append("HTTPS-only session cookies")
    return pending