"""Web UI routes — Jinja2 templates served by FastAPI. Replicates all Flask webapp routes with DuckDB-backed data. """ import logging import os from datetime import datetime, timezone from pathlib import Path from typing import Optional from urllib.parse import quote from fastapi import APIRouter, Depends, Request, HTTPException from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates import duckdb import jinja2 from app.auth.access import is_user_admin, require_admin from app.auth.dependencies import get_current_user, get_optional_user, _get_db from app.instance_config import ( get_instance_name, get_instance_subtitle, get_datasets, get_theme, get_corporate_memory_config, get_home_route, get_gws_oauth_credentials, get_home_automode_visibility, get_instance_admin_email, get_atlassian_base_url, get_instance_brand, get_workspace_dir_name, ) from app.web.connector_prompts import all_connector_prompts from src.repositories.sync_state import SyncStateRepository from src.repositories.sync_settings import SyncSettingsRepository from src.repositories.knowledge import KnowledgeRepository from src.repositories.users import UserRepository from src.repositories.profiles import ProfileRepository def _resolved_home_route() -> str: """Lazy wrapper so tests/monkeypatch on env vars are honoured per-request.""" return get_home_route() logger = logging.getLogger(__name__) router = APIRouter(tags=["web"]) TEMPLATES_DIR = Path(__file__).parent / "templates" templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) # Make templates tolerant of missing variables (renders empty string instead of error) class _SilentUndefined(jinja2.Undefined): """Silently handle any access on undefined variables — returns empty/falsy.""" def __str__(self): return "" def __iter__(self): return iter([]) def __bool__(self): return False def __len__(self): return 0 def __getattr__(self, name): return self def __getitem__(self, name): return self def __call__(self, *args, **kwargs): return self def __int__(self): return 0 templates.env.undefined = _SilentUndefined # Add custom JSON filter that handles _SilentUndefined and _FlexDict import json as _json class _SafeEncoder(_json.JSONEncoder): def default(self, obj): if isinstance(obj, (_SilentUndefined, _FlexDict)): if isinstance(obj, _FlexDict) and dict.__len__(obj) > 0: return dict(obj) return None return super().default(obj) templates.env.policies["json.dumps_function"] = lambda obj, **kw: _json.dumps(obj, cls=_SafeEncoder, **kw) def _humanbytes(value) -> str: """Render a byte count as the largest binary-prefixed unit it fits in. Below 1 KiB → integer bytes; otherwise two decimal places of KB / MB / GB (binary, 1024-based). Used by the Store detail template; intentionally permissive about input type so missing / undefined values render as ``0 B`` rather than crashing the page. """ try: n = int(value or 0) except (TypeError, ValueError): return "0 B" if n < 1024: return f"{n} B" kb = n / 1024 if kb < 1024: return f"{kb:.2f} KB" mb = kb / 1024 if mb < 1024: return f"{mb:.2f} MB" gb = mb / 1024 return f"{gb:.2f} GB" templates.env.filters["humanbytes"] = _humanbytes def _store_display_name(name: str | None) -> str: """Strip the archive-rename suffix from a store entity's display name so admin queue / my-stack / detail templates show the original label instead of the internal `__archived__` marker. Safe on plain (non-archived) names — no-op.""" from src.store_naming import strip_archive_suffix return strip_archive_suffix(name or "") templates.env.filters["store_display_name"] = _store_display_name # ---- PostHog template wiring ---- # Two Jinja globals injected into every render so the `_posthog.html` partial # (included from `base.html` and `base_login.html`) can render the browser # snippet — or render nothing when the integration is disabled. # # posthog_config process-level static config (host, project key, # replay flag, extra mask selector). Resolved # once on first access. # posthog_user_block(request) per-request identify payload honoring the # operator-chosen identify mode. Returns None # for anonymous renders. def _posthog_config_global() -> dict: from src.observability import get_posthog pc = get_posthog() if not pc.enabled: return {"enabled": False} return { "enabled": True, "host": pc.host, "api_key_public": pc.api_key_public, "replay_enabled": pc.replay_enabled, "replay_mask_selector_extra": pc.replay_mask_selector_extra, "environment": pc.environment, "release": pc.release, } def _posthog_user_block(request: Optional[Request]) -> Optional[dict]: from src.observability import get_posthog pc = get_posthog() if not pc.enabled: return None mode = pc.identify_mode if mode == "none": return None user = None if request is not None: try: user = getattr(request.state, "user", None) except Exception: user = None if not user: return None def _get(attr: str): if isinstance(user, dict): return user.get(attr) return getattr(user, attr, None) distinct_id = _get("id") or _get("user_id") or _get("email") if not distinct_id: return None props: dict = {} if mode in ("email", "full"): email = _get("email") if email: props["email"] = str(email) if mode == "full": name = _get("name") or _get("full_name") if name: props["name"] = str(name) return {"distinct_id": str(distinct_id), "props": props} templates.env.globals["posthog_config"] = _posthog_config_global() templates.env.globals["posthog_user_block"] = _posthog_user_block class _FlexDict(dict): """Dict that returns empty _FlexDict for missing keys and attributes. Prevents Jinja2 UndefinedError when templates access missing nested values.""" def __getattr__(self, name): try: return self[name] except KeyError: return _FlexDict() def __bool__(self): return bool(dict.__len__(self)) def __str__(self): return "" def __int__(self): return 0 def __float__(self): return 0.0 def __iter__(self): return iter(dict.values(self)) if dict.__len__(self) else iter([]) def __len__(self): return dict.__len__(self) def __call__(self, *args, **kwargs): return "" def __add__(self, other): return other def __radd__(self, other): return other def __sub__(self, other): return 0 - other if isinstance(other, (int, float)) else self def __rsub__(self, other): return other def __mul__(self, other): return 0 def __rmul__(self, other): return 0 def __truediv__(self, other): return 0 def __rtruediv__(self, other): return 0 def __mod__(self, other): return 0 def __eq__(self, other): return False if dict.__len__(self) == 0 else dict.__eq__(self, other) def __ne__(self, other): return True if dict.__len__(self) == 0 else dict.__ne__(self, other) def __lt__(self, other): return False def __gt__(self, other): return False def __le__(self, other): return True def __ge__(self, other): return True def __contains__(self, item): return dict.__contains__(self, item) if dict.__len__(self) else False def _flex(d): """Recursively convert dicts to _FlexDict for template compatibility.""" if isinstance(d, dict) and not isinstance(d, _FlexDict): return _FlexDict({k: _flex(v) for k, v in d.items()}) if isinstance(d, list): return [_flex(i) for i in d] return d _URL_MAP = { # Flask-style endpoint names → FastAPI URL paths "dashboard": "/dashboard", "catalog": "/catalog", "corporate_memory": "/corporate-memory", "corporate_memory_admin": "/corporate-memory/admin", "activity_center": "/activity-center", "admin_activity": "/admin/activity", "index": "/", "auth.login": "/login", "auth.logout": "/login", # No logout route — redirect to login "password_auth.login_email": "/auth/password/login", "password_auth.reset_request": "/auth/password/reset", "password_auth.request_access": "/auth/password/setup", "email_auth.login_email_form": "/login/email", "email_auth.send_magic_link": "/auth/email/send-link", "register": "/auth/password/setup", "setup": "/first-time-setup", } def _url_for_shim(endpoint: str, **kw) -> str: """Flask url_for compatibility — maps endpoint names to FastAPI paths.""" if endpoint == "static": filename = kw.get("filename", "") return f"/static/{filename}" return _URL_MAP.get(endpoint, f"/{endpoint}") def _read_agnes_ca_pem() -> Optional[str]: """Read the Agnes server's TLS fullchain for inlining into the setup prompt. Returns the PEM string when the cert needs trust-bootstrapping — self-signed (leaf issuer == subject), private-CA chain that doesn't terminate in a `certifi`-known root, or any case where we can't cheaply prove the OS would trust it. Returns None when the chain in the served fullchain.pem terminates in a publicly-trusted root that `certifi` already ships (Let's Encrypt's ISRG Root X1, DigiCert, etc.) — clients (Bun-compiled `claude.exe`, system git, Python with certifi) all accept the chain without help. Chain validation walks every cert in the served fullchain and succeeds the first time any cert's issuer matches a `certifi` root subject. That captures the standard fullchain shape (leaf + intermediate(s)) where `intermediate.issuer == publicly_trusted_root`, even though the leaf's *immediate* issuer is the intermediate (which is rarely shipped in trust stores — only roots are). Inlining a publicly-trusted cert is harmless (clients already trust it via OS roots), but it bloats the prompt and steers users into setting SSL_CERT_FILE unnecessarily, which narrows their Python TLS trust to just this host. So skip when we can confirm broad trust. Path is configurable via AGNES_TLS_FULLCHAIN_PATH (defaults to `/data/state/certs/fullchain.pem`, the location `agnes-tls-rotate.sh` writes on every VM and `docker-compose.host-mount.yml` rbinds into the app container). Missing / unreadable / unparseable → None, and the setup prompt falls back to its pre-cert behavior. """ path = Path(os.environ.get("AGNES_TLS_FULLCHAIN_PATH", "/data/state/certs/fullchain.pem")) try: if not path.is_file(): return None pem = path.read_text(encoding="utf-8") except OSError: return None if "-----BEGIN CERTIFICATE-----" not in pem: return None try: from cryptography import x509 chain = x509.load_pem_x509_certificates(pem.encode("utf-8")) if not chain: return None leaf = chain[0] if leaf.issuer == leaf.subject: # Self-signed — definitely needs bootstrap on the client. return pem # CA-signed leaf: walk every cert in the served fullchain (leaf + # intermediates) and check whether ANY of their issuers is in # `certifi`'s trust store. The first match means the chain # terminates in a publicly-trusted root, so the client OS / Bun # bundle / certifi already accept it. try: import certifi with open(certifi.where(), "rb") as fh: trust_pem = fh.read() except Exception: return pem # can't enumerate trust → assume bootstrap needed trusted_subjects = { ca.subject.rfc4514_string() for ca in x509.load_pem_x509_certificates(trust_pem) } for cert in chain: if cert.issuer.rfc4514_string() in trusted_subjects: return None # publicly trusted; client OS already accepts return pem except Exception: # pragma: no cover — defensive: bad PEM / x509 error logger.exception("Failed to evaluate Agnes TLS cert; skipping inline") return None def _build_context( request: Request, user: Optional[dict] = None, conn: Optional[duckdb.DuckDBPyConnection] = None, **extra, ) -> dict: """Build template context with config, user, and theme. `conn` is optional: when supplied alongside a logged-in `user`, the setup-prompt preview/clipboard payload is rendered with that user's RBAC-allowed Claude Code marketplace plugins inlined as install commands. Routes that don't render the env-setup-cta block can omit it. """ class ConfigProxy: INSTANCE_NAME = get_instance_name() INSTANCE_SUBTITLE = get_instance_subtitle() INSTANCE_COPYRIGHT = "" LOGO_SVG = "" TELEGRAM_BOT_USERNAME = os.environ.get("TELEGRAM_BOT_USERNAME", "") SSH_ALIAS = "data-analyst" SERVER_HOST = os.environ.get("SERVER_HOST", "") PROJECT_DIR = "data-analyst" # Drives whether the user dropdown renders the "Auth debug" link. # Same env var the route guard checks — keep them in lock-step so # the link never appears when the route would 404, and vice versa. DEBUG_AUTH_ENABLED = os.environ.get("AGNES_DEBUG_AUTH", "").strip().lower() in ( "1", "true", "yes", ) # Google Workspace prefix-mapping config — surfaced into templates # so client-side JS can derive a friendly display name from the # full Workspace email stored as the group's `name` (admin UI # strips the prefix and `@domain` for the big line, keeps the # full email as subtitle). Read at template render time so an # operator can flip these via env without an image rebuild. AGNES_GOOGLE_GROUP_PREFIX = os.environ.get( "AGNES_GOOGLE_GROUP_PREFIX", "" ) AGNES_GROUP_ADMIN_EMAIL = os.environ.get( "AGNES_GROUP_ADMIN_EMAIL", "" ) AGNES_GROUP_EVERYONE_EMAIL = os.environ.get( "AGNES_GROUP_EVERYONE_EMAIL", "" ) @staticmethod def theme_overrides(): theme = get_theme() # Return dict of CSS variable overrides (only non-empty values) if isinstance(theme, dict): return {k: v for k, v in theme.items() if v} return {} ctx_server_url = str(request.base_url).rstrip("/") # Lines for the "Setup a new Claude Code" preview/clipboard partial. # # When a DB connection is available, we go through render_agent_prompt_banner # which checks for an admin override first (stored in welcome_template) and # falls back to the live default from setup_instructions.resolve_lines(). # This guarantees that both /setup and /dashboard clipboard CTA always reflect # the same content — the override is honoured everywhere. # # When no conn is supplied (e.g. public pages that don't need a DB round-trip) # we fall back to resolve_lines() directly with anonymous/no-plugin context. if conn is not None: from src.welcome_template import render_agent_prompt_banner _script_text = render_agent_prompt_banner( conn, user=user, server_url=ctx_server_url ) setup_instructions_lines = _script_text.split("\n") else: # No DB connection — use the unauthenticated default (no override possible, # no marketplace plugins). from app.web.setup_instructions import resolve_lines from app.api.cli_artifacts import _find_wheel _wheel = _find_wheel() _wheel_filename = _wheel.name if _wheel else "agnes.whl" server_host = request.url.netloc ca_pem = _read_agnes_ca_pem() # Connector prompts wired through so step 9 inlines the same text # the /home tiles render. all_connector_prompts() reads operator # GWS OAuth config so the GCP-frictionless branch fires when the # admin has provisioned a shared client_id+secret. _connector_prompts = all_connector_prompts( gws_oauth=get_gws_oauth_credentials(), instance_admin_email=get_instance_admin_email(), atlassian_base_url=get_atlassian_base_url(), instance_brand=get_instance_brand(), ) setup_instructions_lines = resolve_lines( _wheel_filename, plugin_install_names=[], server_host=server_host, ca_pem=ca_pem, connector_prompts=_connector_prompts, instance_brand=get_instance_brand(), workspace_dir=get_workspace_dir_name(), ) ctx = { "request": request, "config": ConfigProxy, "user": _flex(user) if user else _FlexDict(), "now": datetime.now, "static_url": lambda path: f"/static/{path}", # Flask compatibility shims for templates "get_flashed_messages": lambda **kwargs: [], "url_for": lambda endpoint, **kw: _url_for_shim(endpoint, **kw), "session": _FlexDict({"user": user}) if user else _FlexDict(), "setup_instructions_lines": setup_instructions_lines, "server_url": ctx_server_url, # Resolved per AGNES_HOME_ROUTE env > instance.home_route YAML > # /dashboard. The shared navbar's "Dashboard" link uses this so a # single env flip routes the primary nav target between /home # (state-aware landing) and /dashboard (legacy table inventory). "home_route": _resolved_home_route(), # Pre-configured Google Workspace CLI OAuth client for the # /home connector prompt. {} when unset → template falls back # to manual `gws auth setup`. See app.instance_config docstring. "gws_oauth": get_gws_oauth_credentials(), # Operator-facing contact email used by the /home GWS connector # tile's "Email admin" mailto button. Empty string hides the # button — template guards with `{% if instance_admin_email %}`. "instance_admin_email": get_instance_admin_email(), # Branding: `instance_name` is the deploying org's display name # (page titles); `instance_brand` is the product name used in body # copy and CTAs ("Setup {brand}", "{brand} runs SELECT…"); `workspace_dir` # is the filesystem-safe folder name shown in `~/` and # baked into the clipboard setup script. All three default to the # Agnes-flavored values out of the box; Terraform can flip them via # env vars (AGNES_INSTANCE_BRAND / AGNES_WORKSPACE_DIR_NAME). "instance_name": get_instance_name(), "instance_brand": get_instance_brand(), "workspace_dir": get_workspace_dir_name(), # Resolved connector setup prompts — single source of truth for # both the /home "Copy prompt" tiles and the main setup script # (app/web/setup_instructions.py inlines them in step 9). The # gws prompt branches on `gws_oauth.configured` so both surfaces # render the operator-provisioned shortcut when credentials are # set, and the manual GCP walkthrough when they're not. "connector_prompts": all_connector_prompts( gws_oauth=get_gws_oauth_credentials(), instance_admin_email=get_instance_admin_email(), atlassian_base_url=get_atlassian_base_url(), instance_brand=get_instance_brand(), ), # Whether /home renders the "Step 3 — turn on auto-accept mode" # install-block. Operator can hide it via AGNES_HOME_SHOW_AUTOMODE=0 # for cautious rollouts; same content stays on /setup-advanced. "home_automode": {"show": get_home_automode_visibility()}, } # Flex all extra context values for template compatibility # (but skip ones we just populated — extras with the same key win) for k, v in extra.items(): ctx[k] = _flex(v) if isinstance(v, (dict, list)) else v return ctx # ---- Navigation ---- @router.get("/", response_class=HTMLResponse) async def index(request: Request, user: Optional[dict] = Depends(get_optional_user)): if user: from app.instance_config import get_home_route return RedirectResponse(url=get_home_route(), status_code=302) return RedirectResponse(url="/login", status_code=302) @router.get("/first-time-setup", response_class=HTMLResponse) async def setup_wizard(request: Request, conn: duckdb.DuckDBPyConnection = Depends(_get_db)): """First-time setup wizard. Redirects to login if users already exist.""" try: user_count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] if user_count > 0: return RedirectResponse(url="/login", status_code=302) except Exception: pass # No users table yet — show setup return templates.TemplateResponse(request, "setup.html", _build_context(request)) @router.get("/login", response_class=HTMLResponse) async def login_page(request: Request): from app.auth.dependencies import is_local_dev_mode, _get_local_dev_user if is_local_dev_mode(): # Only short-circuit to the home route if the dev user is actually # seeded. Otherwise a 401 there would bounce back to /login and loop. from src.db import get_system_db conn = get_system_db() try: if _get_local_dev_user(conn): return RedirectResponse(url=get_home_route(), status_code=302) finally: conn.close() # Fall through to the normal login form so the missing-seed error is visible. next_path = request.query_params.get("next", "") if not next_path.startswith("/") or next_path.startswith("//"): next_path = "" providers = [] try: from app.auth.providers.google import is_available as google_available if google_available(): providers.append({"name": "google", "display_name": "Google", "icon": "google"}) except Exception: pass providers.append({"name": "password", "display_name": "Email & Password", "icon": "key"}) try: from app.auth.providers.email import is_available as email_available if email_available(): providers.append({"name": "email", "display_name": "Email Link", "icon": "mail"}) except Exception: pass # Convert to login_buttons format expected by template login_buttons = [] for p in providers: if p["name"] == "google": _url = "/auth/google/login" if next_path: _url += f"?next={quote(next_path, safe='')}" login_buttons.append({"url": _url, "text": "Sign in with Google", "css_class": "btn-primary", "icon_html": ""}) elif p["name"] == "password": _url = "/login/password" if next_path: _url += f"?next={quote(next_path, safe='')}" login_buttons.append({"url": _url, "text": "Sign in with Email & Password", "css_class": "btn-secondary", "icon_html": ""}) elif p["name"] == "email": _url = "/login/email" if next_path: _url += f"?next={quote(next_path, safe='')}" login_buttons.append({"url": _url, "text": "Sign in with Email Link", "css_class": "btn-secondary", "icon_html": ""}) ctx = _build_context(request, providers=providers, login_buttons=login_buttons, next_path=next_path) return templates.TemplateResponse(request, "login.html", ctx) @router.get("/login/password", response_class=HTMLResponse) async def login_password_page(request: Request): """Password login form (email + password).""" next_path = request.query_params.get("next", "") if not next_path.startswith("/") or next_path.startswith("//"): next_path = "" google_ok = False try: from app.auth.providers.google import is_available as google_available google_ok = google_available() except Exception: pass ctx = _build_context(request, google_available=google_ok, next_path=next_path) return templates.TemplateResponse(request, "login_email.html", ctx) @router.get("/login/email", response_class=HTMLResponse) async def login_email_page(request: Request): """Email magic link login form.""" next_path = request.query_params.get("next", "") if not next_path.startswith("/") or next_path.startswith("//"): next_path = "" google_ok = False try: from app.auth.providers.google import is_available as google_available google_ok = google_available() except Exception: pass ctx = _build_context(request, google_available=google_ok, next_path=next_path) return templates.TemplateResponse(request, "login_email.html", ctx) @router.get("/dashboard", response_class=HTMLResponse) async def dashboard( request: Request, user: dict = Depends(get_current_user), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): sync_repo = SyncStateRepository(conn) settings_repo = SyncSettingsRepository(conn) profile_repo = ProfileRepository(conn) all_states = sync_repo.get_all_states() enabled_datasets = settings_repo.get_enabled_datasets(user["id"]) datasets = get_datasets() # Stats total_tables = len(all_states) total_rows = sum(s.get("rows", 0) or 0 for s in all_states) # Build user_info object expected by dashboard template is_admin = is_user_admin(user["id"], conn) class UserInfo: def __init__(self): self.exists = True self.is_admin = is_admin # Legacy fields kept so existing templates don't blow up — admin is # implicitly analyst/privileged, non-admins are not. Granular roles # collapsed in v12. self.is_analyst = is_admin self.is_privileged = is_admin self.username = user.get("email", "").split("@")[0] self.home_dir = "" self.groups = [] ctx = _build_context( request, user=user, conn=conn, user_info=UserInfo(), username=user.get("email", "").split("@")[0], total_tables=total_tables, total_rows=total_rows, sync_states=all_states, enabled_datasets=enabled_datasets, datasets=datasets, account_status="active", account_details=None, telegram_status={"linked": False}, data_stats={ "tables": total_tables, "total_tables": total_tables, "columns": 0, "rows_display": f"{total_rows:,}" if total_rows else "0", "size_display": "0 MB", "unstructured_display": "0 MB", "total_rows": total_rows, "last_updated": None, "remote_tables": 0, "local_tables": total_tables, }, categories=[], metrics_data=[], desktop_status={"linked": False}, activity_summary={"total_sessions": 0, "total_queries": 0}, knowledge_stats={"total": 0, "approved": 0}, user_knowledge_stats={"authored": 0, "votes_given": 0}, ) return templates.TemplateResponse(request, "dashboard.html", ctx) @router.get("/home", response_class=HTMLResponse) async def home_page( request: Request, user: dict = Depends(get_current_user), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): """State-aware /home — full inline install for not-onboarded users, clean nav hub once onboarded. The boolean drives template selection; no auto-transition (manual reload picks up the flip after ``agnes init`` POSTs ``/api/me/onboarded``). See origin: docs/brainstorms/home-page-requirements.md. """ row = conn.execute( "SELECT onboarded FROM users WHERE id = ?", [user["id"]] ).fetchone() onboarded = bool(row[0]) if row else False # Pull the latest published news intro for the bottom-of-page section. # Template renders the section only when intro is non-empty, so an # instance that has never published news shows nothing extra. from src.repositories.news_template import NewsTemplateRepository news = NewsTemplateRepository(conn).get_current_published() news_intro = news["intro"] if (news and news.get("intro")) else "" # Single template renders both states. The post-onboarding view keeps # the install-steps + connector prompts + auto-mode card visible — # they stay relevant for adding a second machine, a missing connector, # or re-running auto-mode setup. Hero copy + the self-mark control # branch on the boolean. The legacy `home_onboarded.html` is kept on # disk for a release as a fallback but no route renders it. ctx = _build_context( request, user=user, conn=conn, onboarded=onboarded, is_admin=is_user_admin(user["id"], conn), news_intro=news_intro, ) return templates.TemplateResponse(request, "home_not_onboarded.html", ctx) @router.get("/news", response_class=HTMLResponse) async def news_page( request: Request, user: dict = Depends(get_current_user), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): """Permalink page for the latest published news. Renders empty-state copy when no version is published. Authed-only (same as /home). """ from src.repositories.news_template import NewsTemplateRepository news = NewsTemplateRepository(conn).get_current_published() ctx = _build_context( request, user=user, conn=conn, is_admin=is_user_admin(user["id"], conn), news=news, ) return templates.TemplateResponse(request, "news.html", ctx) @router.get("/admin/news", response_class=HTMLResponse) async def admin_news_editor( request: Request, user: dict = Depends(require_admin), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): """Admin authoring surface — current published banner, draft editor, versions table. JS hits the /api/admin/news/* endpoints for the write paths.""" from src.repositories.news_template import NewsTemplateRepository repo = NewsTemplateRepository(conn) ctx = _build_context( request, user=user, conn=conn, is_admin=True, news_current=repo.get_current_published(), news_draft=repo.get_active_draft(), news_versions=repo.list_versions(limit=50), ) return templates.TemplateResponse(request, "admin/news_editor.html", ctx) @router.get("/setup-advanced", response_class=HTMLResponse) async def setup_advanced_page( request: Request, user: dict = Depends(get_current_user), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): """Advanced setup reference — VS Code layout, recommended plugins, multi-model second opinions, custom skills, cost guidance. Pulls the deeper Chief-of-Stuff guide content out of /home so /home stays scannable for first-hour onboarding. Linked from /home's "Want to look around first?" explore card and from any deep-link anchors emitted by other pages (e.g. /home's auto-mode block points at #yolo). """ ctx = _build_context( request, user=user, conn=conn, is_admin=is_user_admin(user["id"], conn), ) return templates.TemplateResponse(request, "setup_advanced.html", ctx) @router.get("/catalog", response_class=HTMLResponse) async def catalog( request: Request, user: dict = Depends(get_current_user), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): sync_repo = SyncStateRepository(conn) settings_repo = SyncSettingsRepository(conn) profile_repo = ProfileRepository(conn) all_states = sync_repo.get_all_states() all_profiles = profile_repo.get_all() enabled_datasets = settings_repo.get_enabled_datasets(user["id"]) datasets = get_datasets() # Build catalog data from table_registry in DuckDB. Filter pre-render so # the page only lists tables the user actually has access to — Admin # group members see everything (can_access shortcut), other users see # only entries with a matching resource_grants(group, "table", id) row. try: from src.repositories.table_registry import TableRegistryRepository from app.auth.access import can_access from app.resource_types import ResourceType table_repo = TableRegistryRepository(conn) registered = table_repo.list_all() user_id = user.get("id", "") tables = [] internal_tables = [] for tc in registered: table_id = tc.get("id", "") if not can_access(user_id, ResourceType.TABLE.value, table_id, conn): continue table_data = { "id": table_id, "name": tc.get("name", ""), "description": tc.get("description", ""), "dataset": tc.get("bucket"), "source_type": tc.get("source_type") or "", "sync_strategy": tc.get("sync_strategy", "full_refresh"), "query_mode": tc.get("query_mode", "local"), "profile": all_profiles.get(table_id), } # Add sync state for state in all_states: if state["table_id"] == table_id: table_data["last_sync"] = state.get("last_sync") table_data["rows"] = state.get("rows") break # Agnes internal tables (agnes_sessions / agnes_telemetry / # agnes_audit) render in a dedicated card on /catalog rather # than under "Core Business Data" — they're system tables, # not business data, but analysts should still discover them # for `agnes query` so they need to live on the catalog page. if tc.get("source_type") == "internal": internal_tables.append(table_data) else: tables.append(table_data) except Exception as e: tables = [] internal_tables = [] logger.warning(f"Could not load catalog: {e}") # Build data_stats for catalog template (business-data card header) total_rows = sum(s.get("rows", 0) or 0 for s in all_states) data_stats = { "total_tables": len(all_states), "total_rows": total_rows, "total_columns": 0, "total_size": sum(s.get("file_size_bytes", 0) or 0 for s in all_states), "last_updated": max((s.get("last_sync") for s in all_states if s.get("last_sync")), default=None), } # Build business-data categories from `tables` (excludes internal). categories = {} for t in tables: ds = t.get("dataset") or "default" if ds not in categories: categories[ds] = {"name": ds, "tables": []} categories[ds]["tables"].append(t) catalog_data = [] for cat in categories.values(): cat["count"] = len(cat["tables"]) catalog_data.append(cat) # Internal-tables card. Single flat list — the three rows already # share one category ("Agnes Internal"), so no accordion grouping is # useful. Template renders them as a plain list under their own card. internal_card = None if internal_tables: internal_card = { "name": "Agnes Internal", "count": len(internal_tables), "tables": internal_tables, } ctx = _build_context( request, user=user, tables=tables, datasets=datasets, enabled_datasets=enabled_datasets, data_stats=data_stats, categories=catalog_data, catalog_data=catalog_data, internal_card=internal_card, metrics_data=[], sync_states=all_states, folder_mapping={}, ) return templates.TemplateResponse(request, "catalog.html", ctx) @router.get("/corporate-memory", response_class=HTMLResponse) async def corporate_memory( request: Request, user: dict = Depends(require_admin), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): """Corporate Memory web view — admin-only. The page route gates on ``require_admin``; non-admin users see 403. The Memory nav link in `_app_header.html` and the corporate-memory widget on `/dashboard` are correspondingly hidden behind ``{% if session.user.is_admin %}`` guards (defence in depth — the backend is the authoritative gate). **Asymmetry**: the underlying ``/api/memory/*`` endpoints stay on ``get_current_user`` (not ``require_admin``). CLI / agent flows that POST a knowledge item or read ``/api/memory`` keep working for any authenticated user. The gating here is web-UI-only — the API is the surface the agent rails care about (`agnes` CLI, knowledge-extract pipeline), and locking it down would break the corporate-memory feature outright. Operators who want to relax the web-UI gate can either grant Admin to those users or revert this route to ``get_current_user`` in their fork. """ repo = KnowledgeRepository(conn) items = repo.list_items(statuses=["approved", "mandatory"], limit=100) # Enrich with votes for item in items: votes = repo.get_votes(item["id"]) item["upvotes"] = votes["upvotes"] item["downvotes"] = votes["downvotes"] cm_config = get_corporate_memory_config() governance_mode = cm_config.get("distribution_mode") # Build stats + filter dropdowns from the full item set so the dropdowns # match the data the page is rendering. `categories` and `domains` are # consumed by the filter pickers in `corporate_memory.html`; without # `domains` the "All domains" picker stays empty. all_items = repo.list_items(limit=10000) categories = sorted(set(i.get("category", "") for i in all_items if i.get("category"))) domains = sorted(set(i.get("domain", "") for i in all_items if i.get("domain"))) # #176: surface the pending review queue to admins. Without this the # main page silently filtered status='pending' items and operators had # no breadcrumb to /corporate-memory/admin. pending_count = sum(1 for i in all_items if i.get("status") == "pending") # "My contributions" — items the caller authored. Personal items are # always visible to their author regardless of audience filtering; # this is the surface the user uses to mark/unmark `is_personal`. user_email = user.get("email") or "" user_contributions = repo.get_user_contributions(user_email) if user_email else [] for item in user_contributions: votes = repo.get_votes(item["id"]) item["upvotes"] = votes["upvotes"] item["downvotes"] = votes["downvotes"] is_admin_view = is_user_admin(user["id"], conn) ctx = _build_context( request, user=user, knowledge_items=items, governance_mode=governance_mode, governance={"mode": governance_mode, "groups": cm_config.get("groups", {})}, categories=categories, domains=domains, stats={"total": len(all_items), "approved": len([i for i in all_items if i.get("status") == "approved"])}, user_votes={}, is_km_admin=is_admin_view, user_contributions=user_contributions, user_stats={"authored": len(user_contributions), "votes_given": 0}, # Template expects knowledge as object with .items and .total_pages knowledge={"items": items, "total_pages": 1, "page": 1, "per_page": 100, "total": len(items)}, total_pages=1, current_page=1, page=1, per_page=100, # #176: pending banner is admin-only. pending_review_count=pending_count if is_admin_view else 0, ) return templates.TemplateResponse(request, "corporate_memory.html", ctx) @router.get("/corporate-memory/admin", response_class=HTMLResponse) async def corporate_memory_admin( request: Request, user: dict = Depends(require_admin), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): repo = KnowledgeRepository(conn) pending = repo.list_items(statuses=["pending"], limit=100) all_items = repo.list_items(limit=10000) status_counts = {} for item in all_items: s = item.get("status", "unknown") status_counts[s] = status_counts.get(s, 0) + 1 # Contradictions tab is server-rendered (no JS fetch on this tab — see # corporate_memory_admin.html). Fetch the unresolved set and enrich each # entry with the title/sensitivity of both sides so the template doesn't # need to re-query per row. contradictions = repo.list_contradictions(resolved=False) item_lookup = {it["id"]: it for it in all_items} for c in contradictions: for side in ("item_a_id", "item_b_id"): base = item_lookup.get(c.get(side)) or {} target = "item_a" if side == "item_a_id" else "item_b" c[target] = { "title": base.get("title", ""), "content": base.get("content", ""), "domain": base.get("domain"), "sensitivity": base.get("sensitivity"), "status": base.get("status"), "hidden": base.get("is_personal", False), } # Duplicate-candidate badge count (issue #62) — unresolved relations only. duplicates_count = conn.execute( "SELECT COUNT(*) FROM knowledge_item_relations " "WHERE relation_type = 'likely_duplicate' AND resolved = FALSE" ).fetchone()[0] # Mandate-form audience picker needs RBAC user_groups, not the # `corporate_memory.groups` YAML section — those are unrelated. # Template expects an array of {name, members_count} so it can render # `