feat(PORT-004A): add workspace bootstrap and scoped settings
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,6 +4,7 @@ __pycache__/
|
|||||||
.env
|
.env
|
||||||
config/secrets.yaml
|
config/secrets.yaml
|
||||||
data/cache/
|
data/cache/
|
||||||
|
data/workspaces/
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
|||||||
17
app/main.py
17
app/main.py
@@ -12,10 +12,12 @@ from typing import Any
|
|||||||
|
|
||||||
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
from nicegui import ui # type: ignore[attr-defined]
|
from nicegui import ui # type: ignore[attr-defined]
|
||||||
|
|
||||||
import app.pages # noqa: F401
|
import app.pages # noqa: F401
|
||||||
from app.api.routes import router as api_router
|
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.cache import CacheService
|
||||||
from app.services.data_service import DataService
|
from app.services.data_service import DataService
|
||||||
from app.services.runtime import set_data_service
|
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")
|
@app.websocket("/ws/updates")
|
||||||
async def websocket_updates(websocket: WebSocket) -> None:
|
async def websocket_updates(websocket: WebSocket) -> None:
|
||||||
manager: ConnectionManager = websocket.app.state.ws_manager
|
manager: ConnectionManager = websocket.app.state.ws_manager
|
||||||
|
|||||||
@@ -222,12 +222,13 @@ class PortfolioRepository:
|
|||||||
|
|
||||||
CONFIG_PATH = Path("data/portfolio_config.json")
|
CONFIG_PATH = Path("data/portfolio_config.json")
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self, config_path: Path | None = None) -> None:
|
||||||
self.CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
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:
|
def save(self, config: PortfolioConfig) -> None:
|
||||||
"""Save configuration to disk."""
|
"""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)
|
json.dump(config.to_dict(), f, indent=2)
|
||||||
|
|
||||||
def load(self) -> PortfolioConfig:
|
def load(self) -> PortfolioConfig:
|
||||||
@@ -235,13 +236,13 @@ class PortfolioRepository:
|
|||||||
|
|
||||||
Returns default configuration if file doesn't exist.
|
Returns default configuration if file doesn't exist.
|
||||||
"""
|
"""
|
||||||
if not self.CONFIG_PATH.exists():
|
if not self.config_path.exists():
|
||||||
default = PortfolioConfig()
|
default = PortfolioConfig()
|
||||||
self.save(default)
|
self.save(default)
|
||||||
return default
|
return default
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(self.CONFIG_PATH) as f:
|
with open(self.config_path) as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
return PortfolioConfig.from_dict(data)
|
return PortfolioConfig.from_dict(data)
|
||||||
except (json.JSONDecodeError, ValueError) as e:
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
|||||||
65
app/models/workspace.py
Normal file
65
app/models/workspace.py
Normal file
@@ -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
|
||||||
@@ -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:
|
def demo_spot_price() -> float:
|
||||||
return 215.0
|
return 215.0
|
||||||
|
|
||||||
@@ -166,7 +175,7 @@ def strategy_metrics(strategy_name: str, scenario_pct: int) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@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")
|
ui.colors(primary="#0f172a", secondary="#1e293b", accent="#0ea5e9")
|
||||||
|
|
||||||
# Header must be at page level, not inside container
|
# 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("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")
|
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"):
|
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
|
active = key == current
|
||||||
link_classes = "rounded-lg px-4 py-2 text-sm font-medium no-underline transition " + (
|
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"
|
"bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900"
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
from app.components import PortfolioOverview
|
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.pages.common import dashboard_page, quick_recommendations, recommendation_style, strategy_catalog
|
||||||
from app.services.alerts import AlertService, build_portfolio_alert_context
|
from app.services.alerts import AlertService, build_portfolio_alert_context
|
||||||
from app.services.runtime import get_data_service
|
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")
|
}.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("/")
|
@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")
|
@ui.page("/overview")
|
||||||
async def overview_page() -> None:
|
def legacy_overview_page(request: Request):
|
||||||
config = get_portfolio_repository().load()
|
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()
|
data_service = get_data_service()
|
||||||
symbol = data_service.default_symbol
|
symbol = data_service.default_symbol
|
||||||
quote = await data_service.get_quote(symbol)
|
quote = await data_service.get_quote(symbol)
|
||||||
@@ -57,6 +115,7 @@ async def overview_page() -> None:
|
|||||||
"Overview",
|
"Overview",
|
||||||
"Portfolio health, LTV risk, and quick strategy guidance for the current GLD-backed loan.",
|
"Portfolio health, LTV risk, and quick strategy guidance for the current GLD-backed loan.",
|
||||||
"overview",
|
"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"):
|
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")
|
ui.label(quote_status).classes("text-sm text-slate-500 dark:text-slate-400")
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
from nicegui import ui
|
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.pages.common import dashboard_page
|
||||||
from app.services.alerts import AlertService, build_portfolio_alert_context
|
from app.services.alerts import AlertService, build_portfolio_alert_context
|
||||||
from app.services.settings_status import save_status_text
|
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")
|
}.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")
|
@ui.page("/settings")
|
||||||
def settings_page() -> None:
|
def legacy_settings_page(request: Request):
|
||||||
"""Settings page with persistent portfolio configuration."""
|
repo = get_workspace_repository()
|
||||||
repo = get_portfolio_repository()
|
workspace_id = request.cookies.get(WORKSPACE_COOKIE, "")
|
||||||
config = repo.load()
|
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()
|
alert_service = AlertService()
|
||||||
|
|
||||||
syncing_entry_basis = False
|
syncing_entry_basis = False
|
||||||
@@ -61,6 +93,7 @@ def settings_page() -> None:
|
|||||||
"Settings",
|
"Settings",
|
||||||
"Configure portfolio assumptions, collateral entry basis, preferred market data inputs, and alert thresholds.",
|
"Configure portfolio assumptions, collateral entry basis, preferred market data inputs, and alert thresholds.",
|
||||||
"settings",
|
"settings",
|
||||||
|
workspace_id=workspace_id,
|
||||||
):
|
):
|
||||||
with ui.row().classes("w-full gap-6 max-lg:flex-col"):
|
with ui.row().classes("w-full gap-6 max-lg:flex-col"):
|
||||||
with ui.card().classes(
|
with ui.card().classes(
|
||||||
@@ -326,7 +359,7 @@ def settings_page() -> None:
|
|||||||
def save_settings() -> None:
|
def save_settings() -> None:
|
||||||
try:
|
try:
|
||||||
new_config = build_preview_config()
|
new_config = build_preview_config()
|
||||||
repo.save(new_config)
|
workspace_repo.save_portfolio_config(workspace_id, new_config)
|
||||||
render_alert_state()
|
render_alert_state()
|
||||||
|
|
||||||
status.set_text(save_status_text(new_config))
|
status.set_text(save_status_text(new_config))
|
||||||
|
|||||||
@@ -16,12 +16,25 @@ def test_homepage_and_options_page_render() -> None:
|
|||||||
|
|
||||||
page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000)
|
page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000)
|
||||||
expect(page).to_have_title("NiceGUI")
|
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=Vault Dashboard").first).to_be_visible(timeout=10000)
|
||||||
expect(page.locator("text=Overview").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=Live quote source:").first).to_be_visible(timeout=15000)
|
||||||
expect(page.locator("text=Alert Status").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.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)
|
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=Backtests").first).to_be_visible(timeout=15000)
|
||||||
expect(page.locator("text=Scenario Form").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
|
assert "RuntimeError" not in body_text
|
||||||
page.screenshot(path=str(ARTIFACTS / "options.png"), full_page=True)
|
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=Settings").first).to_be_visible(timeout=15000)
|
||||||
expect(page.locator("text=Collateral entry basis").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)
|
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)
|
settings_text = page.locator("body").inner_text(timeout=15000)
|
||||||
assert "RuntimeError" not in settings_text
|
assert "RuntimeError" not in settings_text
|
||||||
assert "Server error" not in settings_text
|
assert "Server error" not in settings_text
|
||||||
page.screenshot(path=str(ARTIFACTS / "settings.png"), full_page=True)
|
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)
|
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=Hedge Analysis").first).to_be_visible(timeout=15000)
|
||||||
expect(page.locator("text=Strategy selector").first).to_be_visible(timeout=15000)
|
expect(page.locator("text=Strategy selector").first).to_be_visible(timeout=15000)
|
||||||
|
|||||||
100
tests/test_workspace.py
Normal file
100
tests/test_workspace.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user