diff --git a/.gitignore b/.gitignore index 01ce20d..eb2ef3c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ __pycache__/ .env config/secrets.yaml data/cache/ +data/workspaces/ .idea/ .vscode/ .worktrees/ diff --git a/app/main.py b/app/main.py index 355e6f8..a0b8a4f 100644 --- a/app/main.py +++ b/app/main.py @@ -12,10 +12,12 @@ from typing import Any from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import RedirectResponse from nicegui import ui # type: ignore[attr-defined] import app.pages # noqa: F401 from app.api.routes import router as api_router +from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository from app.services.cache import CacheService from app.services.data_service import DataService from app.services.runtime import set_data_service @@ -146,6 +148,21 @@ async def health(request: Request) -> dict[str, Any]: } +@app.get("/workspaces/bootstrap", tags=["workspace"]) +async def bootstrap_workspace() -> RedirectResponse: + workspace_id = get_workspace_repository().create_workspace_id() + response = RedirectResponse(url=f"/{workspace_id}", status_code=303) + response.set_cookie( + key=WORKSPACE_COOKIE, + value=workspace_id, + httponly=True, + samesite="lax", + max_age=60 * 60 * 24 * 365, + path="/", + ) + return response + + @app.websocket("/ws/updates") async def websocket_updates(websocket: WebSocket) -> None: manager: ConnectionManager = websocket.app.state.ws_manager diff --git a/app/models/portfolio.py b/app/models/portfolio.py index 4bec44e..41d5b2d 100644 --- a/app/models/portfolio.py +++ b/app/models/portfolio.py @@ -222,12 +222,13 @@ class PortfolioRepository: CONFIG_PATH = Path("data/portfolio_config.json") - def __init__(self) -> None: - self.CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + def __init__(self, config_path: Path | None = None) -> None: + self.config_path = config_path or self.CONFIG_PATH + self.config_path.parent.mkdir(parents=True, exist_ok=True) def save(self, config: PortfolioConfig) -> None: """Save configuration to disk.""" - with open(self.CONFIG_PATH, "w") as f: + with open(self.config_path, "w") as f: json.dump(config.to_dict(), f, indent=2) def load(self) -> PortfolioConfig: @@ -235,13 +236,13 @@ class PortfolioRepository: Returns default configuration if file doesn't exist. """ - if not self.CONFIG_PATH.exists(): + if not self.config_path.exists(): default = PortfolioConfig() self.save(default) return default try: - with open(self.CONFIG_PATH) as f: + with open(self.config_path) as f: data = json.load(f) return PortfolioConfig.from_dict(data) except (json.JSONDecodeError, ValueError) as e: diff --git a/app/models/workspace.py b/app/models/workspace.py new file mode 100644 index 0000000..f4806f0 --- /dev/null +++ b/app/models/workspace.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import re +from pathlib import Path +from uuid import uuid4 + +from app.models.portfolio import PortfolioConfig, PortfolioRepository + +WORKSPACE_COOKIE = "workspace_id" +_WORKSPACE_ID_RE = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", + re.IGNORECASE, +) + + +class WorkspaceRepository: + """Persist workspace-scoped portfolio configuration on disk.""" + + def __init__(self, base_path: Path | str = Path("data/workspaces")) -> None: + self.base_path = Path(base_path) + self.base_path.mkdir(parents=True, exist_ok=True) + + def is_valid_workspace_id(self, workspace_id: str) -> bool: + return bool(_WORKSPACE_ID_RE.match(workspace_id)) + + def workspace_exists(self, workspace_id: str) -> bool: + if not self.is_valid_workspace_id(workspace_id): + return False + return self._portfolio_path(workspace_id).exists() + + def create_workspace(self, workspace_id: str | None = None) -> PortfolioConfig: + resolved_workspace_id = workspace_id or str(uuid4()) + if not self.is_valid_workspace_id(resolved_workspace_id): + raise ValueError("workspace_id must be a UUID4 string") + config = PortfolioConfig() + self.save_portfolio_config(resolved_workspace_id, config) + return config + + def create_workspace_id(self) -> str: + workspace_id = str(uuid4()) + self.create_workspace(workspace_id) + return workspace_id + + def load_portfolio_config(self, workspace_id: str) -> PortfolioConfig: + if not self.workspace_exists(workspace_id): + raise FileNotFoundError(f"Unknown workspace: {workspace_id}") + return PortfolioRepository(self._portfolio_path(workspace_id)).load() + + def save_portfolio_config(self, workspace_id: str, config: PortfolioConfig) -> None: + if not self.is_valid_workspace_id(workspace_id): + raise ValueError("workspace_id must be a UUID4 string") + PortfolioRepository(self._portfolio_path(workspace_id)).save(config) + + def _portfolio_path(self, workspace_id: str) -> Path: + return self.base_path / workspace_id / "portfolio_config.json" + + +_workspace_repo: WorkspaceRepository | None = None + + +def get_workspace_repository() -> WorkspaceRepository: + global _workspace_repo + if _workspace_repo is None: + _workspace_repo = WorkspaceRepository() + return _workspace_repo diff --git a/app/pages/common.py b/app/pages/common.py index 49cc79d..5095c12 100644 --- a/app/pages/common.py +++ b/app/pages/common.py @@ -18,6 +18,15 @@ NAV_ITEMS: list[tuple[str, str, str]] = [ ] +def nav_items(workspace_id: str | None = None) -> list[tuple[str, str, str]]: + if not workspace_id: + return NAV_ITEMS + return [ + ("overview", f"/{workspace_id}", "Overview"), + ("settings", f"/{workspace_id}/settings", "Settings"), + ] + + def demo_spot_price() -> float: return 215.0 @@ -166,7 +175,7 @@ def strategy_metrics(strategy_name: str, scenario_pct: int) -> dict[str, Any]: @contextmanager -def dashboard_page(title: str, subtitle: str, current: str) -> Iterator[ui.column]: +def dashboard_page(title: str, subtitle: str, current: str, workspace_id: str | None = None) -> Iterator[ui.column]: ui.colors(primary="#0f172a", secondary="#1e293b", accent="#0ea5e9") # Header must be at page level, not inside container @@ -179,7 +188,7 @@ def dashboard_page(title: str, subtitle: str, current: str) -> Iterator[ui.colum ui.label("Vault Dashboard").classes("text-lg font-bold text-slate-900 dark:text-slate-50") ui.label("NiceGUI hedging cockpit").classes("text-xs text-slate-500 dark:text-slate-400") with ui.row().classes("items-center gap-2 max-sm:flex-wrap"): - for key, href, label in NAV_ITEMS: + for key, href, label in nav_items(workspace_id): active = key == current link_classes = "rounded-lg px-4 py-2 text-sm font-medium no-underline transition " + ( "bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900" diff --git a/app/pages/overview.py b/app/pages/overview.py index 276bf11..0c57fd3 100644 --- a/app/pages/overview.py +++ b/app/pages/overview.py @@ -2,10 +2,12 @@ from __future__ import annotations from datetime import datetime, timezone +from fastapi import Request +from fastapi.responses import RedirectResponse from nicegui import ui from app.components import PortfolioOverview -from app.models.portfolio import get_portfolio_repository +from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository from app.pages.common import dashboard_page, quick_recommendations, recommendation_style, strategy_catalog from app.services.alerts import AlertService, build_portfolio_alert_context from app.services.runtime import get_data_service @@ -31,10 +33,66 @@ def _alert_badge_classes(severity: str) -> str: }.get(severity, "rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700") +def _render_workspace_recovery(title: str, message: str) -> None: + with ui.column().classes("mx-auto mt-24 w-full max-w-2xl gap-6 px-6 text-center"): + ui.icon("folder_off").classes("mx-auto text-6xl text-slate-400") + ui.label(title).classes("text-3xl font-bold text-slate-900 dark:text-slate-50") + ui.label(message).classes("text-base text-slate-500 dark:text-slate-400") + with ui.row().classes("mx-auto gap-3"): + ui.link("Get started", "/workspaces/bootstrap").classes( + "rounded-lg bg-slate-900 px-5 py-3 text-sm font-semibold text-white no-underline dark:bg-slate-100 dark:text-slate-900" + ) + ui.link("Go to welcome page", "/").classes( + "rounded-lg border border-slate-300 px-5 py-3 text-sm font-semibold text-slate-700 no-underline dark:border-slate-700 dark:text-slate-200" + ) + + @ui.page("/") +def welcome_page(request: Request): + repo = get_workspace_repository() + workspace_id = request.cookies.get(WORKSPACE_COOKIE, "") + if workspace_id and repo.workspace_exists(workspace_id): + return RedirectResponse(url=f"/{workspace_id}", status_code=307) + + with ui.column().classes("mx-auto mt-24 w-full max-w-3xl gap-8 px-6"): + with ui.card().classes( + "w-full rounded-3xl border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-800 dark:bg-slate-900" + ): + ui.label("Vault Dashboard").classes("text-sm font-semibold uppercase tracking-[0.2em] text-sky-600") + ui.label("Create a private workspace URL").classes("text-4xl font-bold text-slate-900 dark:text-slate-50") + ui.label( + "Start with a workspace-scoped overview and settings area. Your portfolio defaults are stored server-side and your browser keeps a workspace cookie for quick return visits." + ).classes("text-base text-slate-500 dark:text-slate-400") + with ui.row().classes("items-center gap-4 pt-4"): + ui.link("Get started", "/workspaces/bootstrap").classes( + "rounded-lg bg-slate-900 px-5 py-3 text-sm font-semibold text-white no-underline dark:bg-slate-100 dark:text-slate-900" + ) + ui.label("You can always create a fresh workspace later if a link is lost.").classes( + "text-sm text-slate-500 dark:text-slate-400" + ) + + @ui.page("/overview") -async def overview_page() -> None: - config = get_portfolio_repository().load() +def legacy_overview_page(request: Request): + repo = get_workspace_repository() + workspace_id = request.cookies.get(WORKSPACE_COOKIE, "") + if workspace_id and repo.workspace_exists(workspace_id): + return RedirectResponse(url=f"/{workspace_id}", status_code=307) + return RedirectResponse(url="/", status_code=307) + + +@ui.page("/{workspace_id}") +@ui.page("/{workspace_id}/overview") +async def overview_page(workspace_id: str) -> None: + repo = get_workspace_repository() + if not repo.workspace_exists(workspace_id): + _render_workspace_recovery( + "Workspace not found", + "The workspace link looks missing or expired. Create a new workspace or return to the welcome page.", + ) + return + + config = repo.load_portfolio_config(workspace_id) data_service = get_data_service() symbol = data_service.default_symbol quote = await data_service.get_quote(symbol) @@ -57,6 +115,7 @@ async def overview_page() -> None: "Overview", "Portfolio health, LTV risk, and quick strategy guidance for the current GLD-backed loan.", "overview", + workspace_id=workspace_id, ): with ui.row().classes("w-full items-center justify-between gap-4 max-md:flex-col max-md:items-start"): ui.label(quote_status).classes("text-sm text-slate-500 dark:text-slate-400") diff --git a/app/pages/settings.py b/app/pages/settings.py index b6f117d..ade1d3c 100644 --- a/app/pages/settings.py +++ b/app/pages/settings.py @@ -1,8 +1,11 @@ from __future__ import annotations +from fastapi import Request +from fastapi.responses import RedirectResponse from nicegui import ui -from app.models.portfolio import PortfolioConfig, get_portfolio_repository +from app.models.portfolio import PortfolioConfig +from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository from app.pages.common import dashboard_page from app.services.alerts import AlertService, build_portfolio_alert_context from app.services.settings_status import save_status_text @@ -16,11 +19,40 @@ def _alert_badge_classes(severity: str) -> str: }.get(severity, "rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700") +def _render_workspace_recovery() -> None: + with ui.column().classes("mx-auto mt-24 w-full max-w-2xl gap-6 px-6 text-center"): + ui.icon("folder_off").classes("mx-auto text-6xl text-slate-400") + ui.label("Workspace not found").classes("text-3xl font-bold text-slate-900 dark:text-slate-50") + ui.label( + "The requested workspace is unavailable. Start a new workspace or return to the welcome page." + ).classes("text-base text-slate-500 dark:text-slate-400") + with ui.row().classes("mx-auto gap-3"): + ui.link("Get started", "/workspaces/bootstrap").classes( + "rounded-lg bg-slate-900 px-5 py-3 text-sm font-semibold text-white no-underline dark:bg-slate-100 dark:text-slate-900" + ) + ui.link("Go to welcome page", "/").classes( + "rounded-lg border border-slate-300 px-5 py-3 text-sm font-semibold text-slate-700 no-underline dark:border-slate-700 dark:text-slate-200" + ) + + @ui.page("/settings") -def settings_page() -> None: - """Settings page with persistent portfolio configuration.""" - repo = get_portfolio_repository() - config = repo.load() +def legacy_settings_page(request: Request): + repo = get_workspace_repository() + workspace_id = request.cookies.get(WORKSPACE_COOKIE, "") + if workspace_id and repo.workspace_exists(workspace_id): + return RedirectResponse(url=f"/{workspace_id}/settings", status_code=307) + return RedirectResponse(url="/", status_code=307) + + +@ui.page("/{workspace_id}/settings") +def settings_page(workspace_id: str) -> None: + """Settings page with workspace-scoped persistent portfolio configuration.""" + workspace_repo = get_workspace_repository() + if not workspace_repo.workspace_exists(workspace_id): + _render_workspace_recovery() + return + + config = workspace_repo.load_portfolio_config(workspace_id) alert_service = AlertService() syncing_entry_basis = False @@ -61,6 +93,7 @@ def settings_page() -> None: "Settings", "Configure portfolio assumptions, collateral entry basis, preferred market data inputs, and alert thresholds.", "settings", + workspace_id=workspace_id, ): with ui.row().classes("w-full gap-6 max-lg:flex-col"): with ui.card().classes( @@ -326,7 +359,7 @@ def settings_page() -> None: def save_settings() -> None: try: new_config = build_preview_config() - repo.save(new_config) + workspace_repo.save_portfolio_config(workspace_id, new_config) render_alert_state() status.set_text(save_status_text(new_config)) diff --git a/tests/test_e2e_playwright.py b/tests/test_e2e_playwright.py index f1b8dbf..54ce6fa 100644 --- a/tests/test_e2e_playwright.py +++ b/tests/test_e2e_playwright.py @@ -16,12 +16,25 @@ def test_homepage_and_options_page_render() -> None: page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000) expect(page).to_have_title("NiceGUI") + expect(page.locator("text=Create a private workspace URL").first).to_be_visible(timeout=10000) + page.get_by_role("link", name="Get started").click() + page.wait_for_url(f"{BASE_URL}/*", timeout=15000) + workspace_url = page.url + workspace_id = workspace_url.removeprefix(f"{BASE_URL}/") + assert workspace_id + cookies = page.context.cookies() + workspace_cookie = next(cookie for cookie in cookies if cookie["name"] == "workspace_id") + assert workspace_cookie["value"] == workspace_id expect(page.locator("text=Vault Dashboard").first).to_be_visible(timeout=10000) expect(page.locator("text=Overview").first).to_be_visible(timeout=10000) expect(page.locator("text=Live quote source:").first).to_be_visible(timeout=15000) expect(page.locator("text=Alert Status").first).to_be_visible(timeout=15000) page.screenshot(path=str(ARTIFACTS / "overview.png"), full_page=True) + page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000) + page.wait_for_url(workspace_url, timeout=15000) + expect(page.locator("text=Alert Status").first).to_be_visible(timeout=15000) + page.goto(f"{BASE_URL}/backtests", wait_until="networkidle", timeout=30000) expect(page.locator("text=Backtests").first).to_be_visible(timeout=15000) expect(page.locator("text=Scenario Form").first).to_be_visible(timeout=15000) @@ -79,15 +92,35 @@ def test_homepage_and_options_page_render() -> None: assert "RuntimeError" not in body_text page.screenshot(path=str(ARTIFACTS / "options.png"), full_page=True) - page.goto(f"{BASE_URL}/settings", wait_until="domcontentloaded", timeout=30000) + page.goto(f"{workspace_url}/settings", wait_until="domcontentloaded", timeout=30000) + assert page.url.endswith("/settings") expect(page.locator("text=Settings").first).to_be_visible(timeout=15000) expect(page.locator("text=Collateral entry basis").first).to_be_visible(timeout=15000) expect(page.locator("text=Entry price ($/oz)").first).to_be_visible(timeout=15000) + budget_input = page.get_by_label("Monthly hedge budget ($)") + budget_input.fill("12345") + page.get_by_role("button", name="Save settings").click() + expect(page.locator("text=Settings saved successfully").first).to_be_visible(timeout=15000) + page.reload(wait_until="domcontentloaded", timeout=30000) + expect(page.get_by_label("Monthly hedge budget ($)")).to_have_value("12345") settings_text = page.locator("body").inner_text(timeout=15000) assert "RuntimeError" not in settings_text assert "Server error" not in settings_text page.screenshot(path=str(ARTIFACTS / "settings.png"), full_page=True) + second_context = browser.new_context(viewport={"width": 1440, "height": 1000}) + second_page = second_context.new_page() + second_page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000) + expect(second_page.locator("text=Create a private workspace URL").first).to_be_visible(timeout=10000) + second_page.get_by_role("link", name="Get started").click() + second_page.wait_for_url(f"{BASE_URL}/*", timeout=15000) + second_workspace_url = second_page.url + assert second_workspace_url != workspace_url + second_page.goto(f"{second_workspace_url}/settings", wait_until="domcontentloaded", timeout=30000) + expect(second_page.get_by_label("Monthly hedge budget ($)")).to_have_value("8000") + second_page.close() + second_context.close() + page.goto(f"{BASE_URL}/hedge", wait_until="domcontentloaded", timeout=30000) expect(page.locator("text=Hedge Analysis").first).to_be_visible(timeout=15000) expect(page.locator("text=Strategy selector").first).to_be_visible(timeout=15000) diff --git a/tests/test_workspace.py b/tests/test_workspace.py new file mode 100644 index 0000000..cbc30de --- /dev/null +++ b/tests/test_workspace.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import re +from uuid import uuid4 + +from fastapi.testclient import TestClient + +from app.main import app +from app.models.workspace import WorkspaceRepository + +UUID4_RE = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", + re.IGNORECASE, +) + + +def _install_workspace_repo(tmp_path, monkeypatch) -> WorkspaceRepository: + from app.models import workspace as workspace_module + + repo = WorkspaceRepository(base_path=tmp_path / "workspaces") + monkeypatch.setattr(workspace_module, "_workspace_repo", repo) + return repo + + +def test_workspace_repository_persists_workspace_specific_portfolio_config(tmp_path) -> None: + repo = WorkspaceRepository(base_path=tmp_path / "workspaces") + workspace_id = str(uuid4()) + + created = repo.create_workspace(workspace_id) + created.loan_amount = 123_456.0 + repo.save_portfolio_config(workspace_id, created) + + reloaded = repo.load_portfolio_config(workspace_id) + + assert repo.workspace_exists(workspace_id) + assert reloaded.loan_amount == 123_456.0 + assert reloaded.gold_value == created.gold_value + + +def test_root_without_workspace_cookie_shows_welcome_page(tmp_path, monkeypatch) -> None: + _install_workspace_repo(tmp_path, monkeypatch) + + with TestClient(app) as client: + response = client.get("/") + + assert response.status_code == 200 + assert "Create a private workspace URL" in response.text + assert "Get started" in response.text + + +def test_bootstrap_endpoint_creates_workspace_cookie_and_redirects(tmp_path, monkeypatch) -> None: + repo = _install_workspace_repo(tmp_path, monkeypatch) + + with TestClient(app) as client: + response = client.get("/workspaces/bootstrap", follow_redirects=False) + + assert response.status_code in {302, 303, 307} + location = response.headers["location"] + workspace_id = location.strip("/") + assert UUID4_RE.match(workspace_id) + assert repo.workspace_exists(workspace_id) + assert response.cookies.get("workspace_id") == workspace_id + + +def test_root_with_valid_workspace_cookie_redirects_to_workspace(tmp_path, monkeypatch) -> None: + repo = _install_workspace_repo(tmp_path, monkeypatch) + workspace_id = str(uuid4()) + repo.create_workspace(workspace_id) + + with TestClient(app) as client: + response = client.get("/", cookies={"workspace_id": workspace_id}, follow_redirects=False) + + assert response.status_code in {302, 303, 307} + assert response.headers["location"] == f"/{workspace_id}" + + +def test_unknown_workspace_route_shows_friendly_recovery_path(tmp_path, monkeypatch) -> None: + _install_workspace_repo(tmp_path, monkeypatch) + + with TestClient(app) as client: + response = client.get("/00000000-0000-4000-8000-000000000000") + + assert response.status_code == 200 + assert "Workspace not found" in response.text + assert "Get started" in response.text + + +def test_workspace_settings_round_trip_uses_workspace_storage(tmp_path, monkeypatch) -> None: + repo = _install_workspace_repo(tmp_path, monkeypatch) + workspace_id = str(uuid4()) + config = repo.create_workspace(workspace_id) + config.monthly_budget = 9_999.0 + repo.save_portfolio_config(workspace_id, config) + + with TestClient(app) as client: + response = client.get(f"/{workspace_id}/settings") + + assert response.status_code == 200 + assert "Settings" in response.text + assert "9,999" in response.text or "9999" in response.text