"""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, get_instance_logo_svg, get_instance_overview, get_instance_theme, ) from app.web.connector_prompts import all_connector_prompts from app.api.me_debug import ( require_debug_auth_enabled, _read_session_token, _decoded_claims, _token_fingerprint, _last_sync_summary, ) 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() _STATIC_DIR = Path(__file__).resolve().parent / "static" def _static_url(path: str) -> str: """Build /static/ with a cache-buster query string. Appends ``?v=`` so a redeploy that changes a CSS/JS file invalidates browser + proxy caches without operator intervention. Missing files return the bare URL — FastAPI's StaticFiles will surface the 404 normally. Cheap (one ``os.stat`` per template variable use). """ full = _STATIC_DIR / path try: v = int(full.stat().st_mtime) return f"/static/{path}?v={v}" except OSError: return f"/static/{path}" 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, precision: int = 2) -> str: """Render a byte count as the largest binary-prefixed unit it fits in. Below 1 KiB → integer bytes; otherwise ``precision`` decimal places of KB / MB / GB / TB (binary, 1024-based). Used by the Store detail template (default 2-decimal precision for fine-grained file sizes) and by the /dashboard stat tiles (1-decimal precision for headline numbers). 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:.{precision}f} KB" mb = kb / 1024 if mb < 1024: return f"{mb:.{precision}f} MB" gb = mb / 1024 if gb < 1024: return f"{gb:.{precision}f} GB" tb = gb / 1024 return f"{tb:.{precision}f} TB" 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": "/admin/corporate-memory", "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 = get_instance_logo_svg() INSTANCE_OVERVIEW = get_instance_overview() 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 the setup script's connector # step inlines them. 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": _static_url, # 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(), # 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(), # Active palette — drives `` in # base.html so `--ds-*` tokens flip via CSS without # touching markup. "navy" (default) = current design; # "blue" = pre-redesign brand. Admin toggles via # /admin/server-config. "instance_theme": get_instance_theme(), # 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` counts REGISTERED business tables, not synced # ones (a registry of 30 with 0 ever synced would otherwise render as # "0"). Internal source_type tables (agnes_*) live in their own card on # /catalog and are excluded from the headline counter. Columns + size # come from sync_state, which is the canonical source for "what's # actually on disk locally". total_tables = conn.execute( "SELECT COUNT(*) FROM table_registry WHERE COALESCE(source_type, '') != 'internal'" ).fetchone()[0] total_rows = sum(s.get("rows", 0) or 0 for s in all_states) total_columns = sum(s.get("columns", 0) or 0 for s in all_states) total_size_bytes = sum(s.get("file_size_bytes", 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": total_columns, "rows_display": f"{total_rows:,}" if total_rows else "0", "size_display": _humanbytes(total_size_bytes, precision=1) if total_size_bytes else "0 MB", "total_rows": total_rows, "last_updated": max( (s.get("last_sync") for s in all_states if s.get("last_sync")), default=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 "" # Homepage status frame (Last sync, Sessions, Prompts, Tokens, Projects). # Gated on (a) operator flag instance.home.show_status_frame / # AGNES_HOME_SHOW_STATUS_FRAME (default on), AND (b) the user being # onboarded — first-day users see a clean install-hero before zero-value # stat cards. When either gate is closed we skip the DB read entirely. from app.api.me import compute_home_stats from app.instance_config import get_home_status_frame_visibility status_frame_enabled = get_home_status_frame_visibility() home_stats = ( compute_home_stats(conn, user, "24h") if (status_frame_enabled and onboarded) else None ) # 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, home_stats=home_stats, status_frame_enabled=status_frame_enabled, ) return templates.TemplateResponse(request, "home_not_onboarded.html", ctx) @router.get("/me/activity", response_class=HTMLResponse) async def me_activity_page( request: Request, user: dict = Depends(get_current_user), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): """Unified personal-activity page — consolidated replacement for the old ``/me/stats`` + ``/profile/sessions`` split. Four tabs (Sessions / Token usage / Data access / Sync activity) backed by ``/api/me/stats/*`` endpoints. The Sessions tab merges usage metrics with verification-pipeline status and download links. """ ctx = _build_context( request, user=user, conn=conn, is_admin=is_user_admin(user["id"], conn), ) return templates.TemplateResponse(request, "me_activity.html", ctx) @router.get("/me/stats", response_class=HTMLResponse) async def me_stats_redirect(request: Request): """Legacy redirect — ``/me/stats`` → ``/me/activity``.""" return RedirectResponse(url="/me/activity", status_code=301) @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) def _data_package_entry_dict(entry, drilldown_url: str, table_count: int = 0, source_types: Optional[list] = None, is_admin_view: bool = False) -> dict: """Adapt a ResourceEntry → template entry dict for the _stack_card macro. Always renders a meta line (`N tables` — even `0 tables`) and a description fallback so packages without an admin-authored description don't render as half-empty cards. Empty-package CTA: when ``table_count == 0`` AND the viewer is admin, the meta line becomes an inline link to ``/admin/tables?assign_to=`` so admins can jump straight into the bulk-assign flow without first having to discover the chip-input hidden in each table's edit modal. """ description = entry.description or ( f"Bundle of {table_count} table{'s' if table_count != 1 else ''}. " f"Add to your stack so `agnes pull` syncs the data locally." ) out = { "id": entry.id, "name": entry.name, "description": description, "icon": entry.icon or "📦", "color": entry.color or "#e0f2fe", # v50: cover image (admin-uploaded JPG/PNG/WebP). _stack_card.html # renders it as when set, falling back to the flat-color + # initials banner when None. Closes the visual gap with # /marketplace cards that have always shown real cover photos. "cover_image_url": getattr(entry, "cover_image_url", None), # v51: lifecycle status + classification category. Drive the # cover-corner status pill and the eyebrow line above the title. "status": getattr(entry, "status", None) or "prod", "category": getattr(entry, "category", None), "requirement": entry.requirement, "in_stack": entry.in_stack, "meta": f"{table_count} table{'s' if table_count != 1 else ''}", # v56: source-type pills (auto-derived) come first per the spec # convention; admin-authored category tags follow. Concatenated # into the single ``tags`` field the macro renders. Duplicates # collapsed via dict-order-preserving filter. "tags": list(dict.fromkeys( list(source_types or []) + list(getattr(entry, "tags", None) or []) )), # v56: extended attribution + derived badges. Macro reads these # via class hooks (data-card-owner, data-badge="..."). "owner_name": getattr(entry, "owner_name", None), "owner_team": getattr(entry, "owner_team", None), "badges": getattr(entry, "badges", None) or [], "drilldown_url": drilldown_url, "footer_left": ( f"View {table_count} table{'s' if table_count != 1 else ''} →" if table_count else "Open →" ), } if table_count == 0 and is_admin_view: # `entry.id` is a server-generated uuid (data_packages.id), safe to # inline. `assign_to` is read by admin_tables.html on load to auto- # open the Bulk Assign modal with this package pre-selected. out["meta_html"] = ( f'0 tables — assign some →' ) return out @router.get("/catalog", response_class=HTMLResponse) async def catalog( request: Request, user: dict = Depends(get_current_user), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): # v49 — unified Browse + My Stack tabs (Task 8.2). The old per-source # source-card / per-table list moved into /catalog/p/ (Task 8.3). from app.services.stack_resolver import StackResolver from app.resource_types import ResourceType from src.repositories.data_packages import DataPackagesRepository resolver = StackResolver(conn) pkg_repo = DataPackagesRepository(conn) # Pre-compute per-package table counts + source-type tag set in one pass # so we don't repeat the join per card. pkg_meta: dict[str, dict] = {} try: for pkg in pkg_repo.list(): tables = pkg_repo.list_tables(pkg["id"]) source_types = sorted({(t.get("source_type") or "") for t in tables if t.get("source_type")}) pkg_meta[pkg["id"]] = { "table_count": len(tables), "source_types": source_types, } except Exception as e: logger.warning("could not enumerate data_packages: %s", e) is_admin_view = is_user_admin(user["id"], conn) if is_admin_view: # Admin god-mode for BROWSE only: surface every package regardless # of group grants so admins can audit the full set. ``browse_admin`` # runs the same v51/v56 enrichment pass as ``browse`` (status, # category, owner_name, tags, derived badges) — re-implementing # it inline silently dropped those fields, leaving admin cards # empty of v56 chrome. For MY STACK we still call the resolver — # admins legitimately subscribe to packages and expect to see them # in their stack tab. browse_entries = resolver.browse_admin(user["id"], ResourceType.DATA_PACKAGE) stack_entries = resolver.stack(user["id"], ResourceType.DATA_PACKAGE) else: browse_entries = resolver.browse(user["id"], ResourceType.DATA_PACKAGE) stack_entries = resolver.stack(user["id"], ResourceType.DATA_PACKAGE) # Group ``required`` packages first so they cluster together at the # top of the Browse grid instead of being scattered by creation # order — first-demo feedback (2026-05-19): "bylo by dobre ty # required mit vzdy nekde seskupene spolu na jedne strane". # Secondary order falls back to the resolver's name-ordered output. browse_entries = sorted( browse_entries, key=lambda e: (0 if e.requirement == "required" else 1, e.name or ""), ) def _adapt(e): slug = None try: full = pkg_repo.get(e.id) if full: slug = full.get("slug") except Exception: slug = None meta = pkg_meta.get(e.id, {}) return _data_package_entry_dict( e, drilldown_url=f"/catalog/p/{slug}" if slug else f"/catalog#{e.id}", table_count=meta.get("table_count", 0), source_types=meta.get("source_types", []), is_admin_view=is_admin_view, ) entries = [_adapt(e) for e in browse_entries] stack_entries_adapted = [_adapt(e) for e in stack_entries] # Aggregate distinct source types across the user's visible packages — # drives the per-source chip row in catalog.html. source_type_chips = sorted({st for e in entries for st in (e.get("tags") or [])}) # Empty-state hint: when no packages exist, the page tells admins how # many tables are already registered (so the CTA "go to /admin/tables # and group them" lands with concrete context). Non-internal tables # only — the agnes_* internal rows aren't analyst-facing. total_registered_tables = 0 try: total_registered_tables = conn.execute( "SELECT COUNT(*) FROM table_registry WHERE COALESCE(source_type, '') != 'internal'" ).fetchone()[0] except Exception: total_registered_tables = 0 # Direct (unbundled) tables on /catalog were dropped per user feedback: # "nemít Direct Tables zvlášť. Potřebujeme to mít celé v nějaké # skupině v těch data packages." Everything an analyst sees here must # belong to a Data Package — admin's job is to package unbundled # tables via Group-by-bucket (one-click) or Bulk-assign on # /admin/tables. The manifest endpoint at /api/sync/manifest still # emits `direct_tables[]` so existing CLI clients with `table`-typed # RBAC grants keep working (BC, not a web surface). ctx = _build_context( request, user=user, entries=entries, stack_entries=stack_entries_adapted, source_type_chips=source_type_chips, total_registered_tables=total_registered_tables, ) return templates.TemplateResponse(request, "catalog.html", ctx) @router.get("/catalog/p/{slug}", response_class=HTMLResponse) async def catalog_package_detail( slug: str, request: Request, user: dict = Depends(get_current_user), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): """Per-package drill-down — header + table list (Task 8.3 of v49 plan). RBAC: admin god-mode or grant on this package. The page mirrors the surface of ``GET /api/data-packages/{slug}`` (which carries the telemetry emit + audit-log path) — the JS side also issues GET on that endpoint so behavior is identical regardless of entry point. """ from app.auth.access import can_access from app.resource_types import ResourceType from app.services.stack_resolver import StackResolver from src.repositories.data_packages import DataPackagesRepository from src.repositories.sync_state import SyncStateRepository from src.repositories.table_registry import TableRegistryRepository from src.repositories.usage import UsageRepository pkg_repo = DataPackagesRepository(conn) pkg = pkg_repo.get_by_slug(slug) if not pkg: raise HTTPException(status_code=404, detail="data_package_not_found") # Admin bypass via is_user_admin; otherwise require a grant (any tier). if not (is_user_admin(user["id"], conn) or can_access(user["id"], ResourceType.DATA_PACKAGE.value, pkg["id"], conn)): raise HTTPException(status_code=403, detail="access_denied") # Telemetry: emit data_package.view (Section 9.2). source=browse|my-stack # passed as ?source=…; default 'direct' for typed/bookmarked navigation. source_hint = request.query_params.get("source", "direct") try: UsageRepository(conn).emit_server_event( event_type="data_package.view", user_id=user["id"], username=user.get("email") or user["id"], props={"slug": slug, "source": source_hint}, ) except Exception: logger.warning("usage_events emit failed for data_package.view") resolver = StackResolver(conn) effective_required = resolver.is_required( user["id"], ResourceType.DATA_PACKAGE, pkg["id"] ) # In-stack iff required OR a subscription row exists. in_stack = effective_required or bool(conn.execute( "SELECT 1 FROM user_stack_subscriptions " "WHERE user_id = ? AND resource_type = 'data_package' AND resource_id = ?", [user["id"], pkg["id"]], ).fetchone()) # Hydrate tables with query_mode + last_sync + v56 extended docs. # The extended fields (grain, platforms, partition_col, history, # gotchas) feed the collapsible per-table extended-detail section # on the package page; description carries the ≤200 char card-line. table_rows = pkg_repo.list_tables(pkg["id"]) table_repo = TableRegistryRepository(conn) sync_states = {s["table_id"]: s for s in SyncStateRepository(conn).get_all_states()} tables = [] for tr in table_rows: full = table_repo.get(tr["id"]) or {} st = sync_states.get(tr["id"]) or {} size = st.get("file_size_bytes") or 0 tables.append({ "id": tr["id"], "name": tr["name"], "description": full.get("description"), "query_mode": full.get("query_mode") or "local", "source_type": full.get("source_type"), "last_sync_display": (str(st.get("last_sync"))[:19] if st.get("last_sync") else None), "size_display": _human_size(size) if size else None, "size_bytes": size, # v56 extended per-table docs for the package-detail expand. "grain": full.get("grain"), "platforms": full.get("platforms") or [], "partition_col": full.get("partition_col"), "history": full.get("history"), "gotchas": full.get("gotchas") or [], "sample_questions": full.get("sample_questions") or [], }) # v56 virtual badges. Derived in-template-aware here so the router # owns the policy (creator-in-admin + 30-day window) and the # template stays presentational. from datetime import datetime, timedelta, timezone as _tz badges: list[str] = [] created_by = pkg.get("created_by") if created_by: admin_match = conn.execute( "SELECT 1 FROM user_group_members ugm " "JOIN user_groups ug ON ug.id = ugm.group_id " "JOIN users u ON u.id = ugm.user_id " "WHERE ug.name = 'Admin' AND (u.email = ? OR u.id = ?) LIMIT 1", [created_by, created_by], ).fetchone() if admin_match: badges.append("curated") created_at = pkg.get("created_at") if isinstance(created_at, datetime): ts = created_at if created_at.tzinfo else created_at.replace(tzinfo=_tz.utc) if (datetime.now(_tz.utc) - ts) < timedelta(days=30): badges.append("new") total_size = sum(t["size_bytes"] for t in tables) ctx = _build_context( request, user=user, pkg=pkg, tables=tables, effective_requirement="required" if effective_required else "available", in_stack=in_stack, total_size_bytes=total_size, total_size_display=_human_size(total_size) if total_size else None, badges=badges, ) return templates.TemplateResponse(request, "catalog_package_detail.html", ctx) @router.get("/catalog/t/{table_id}", response_class=HTMLResponse) async def catalog_table_detail( table_id: str, request: Request, user: dict = Depends(get_current_user), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): """Per-table drill-down — sample questions, columns, things to know, pairs-well-with. Closes the "/catalog detail bounces into /admin" UX gap: this is the user-facing surface for table docs, and admins edit those docs inline on the same page instead of round-tripping through /admin/tables. RBAC: admin god-mode or grant on ANY data package containing this table. Falls back to 403 otherwise — analysts only see tables that belong to packages they're granted on. """ from app.auth.access import can_access from app.resource_types import ResourceType from src.repositories.data_packages import DataPackagesRepository from src.repositories.sync_state import SyncStateRepository from src.repositories.table_registry import TableRegistryRepository table_repo = TableRegistryRepository(conn) table = table_repo.get(table_id) if not table: raise HTTPException(status_code=404, detail="table_not_found") # Find every package that includes this table; gate access on # admin god-mode OR a grant on ANY of those packages. pkg_repo = DataPackagesRepository(conn) parent_packages = [] is_admin = is_user_admin(user["id"], conn) has_grant = False try: # Walk packages (instances are small enough that this is fine). for p in pkg_repo.list(limit=10000): mem_ids = {t["id"] for t in pkg_repo.list_tables(p["id"])} if table_id not in mem_ids: continue parent_packages.append({"slug": p["slug"], "name": p["name"]}) if not has_grant and not is_admin: if can_access(user["id"], ResourceType.DATA_PACKAGE.value, p["id"], conn): has_grant = True except Exception: logger.warning("could not enumerate parent packages for %s", table_id) if not (is_admin or has_grant): raise HTTPException(status_code=403, detail="access_denied") # Resolve any pairs_well_with ids to (id, name) pairs the template # can render as links. Unknown ids (deleted tables) silently dropped. pairs = [] for related_id in (table.get("pairs_well_with") or []): related = table_repo.get(related_id) if related: pairs.append({"id": related["id"], "name": related["name"]}) # Columns from /api/admin/tables/{id}/profile if it exists in # table_profiles, else empty. Cheap read; non-admin doesn't need # the full profile, just the column list. columns = [] try: prof_row = conn.execute( "SELECT profile FROM table_profiles WHERE table_id = ?", [table_id], ).fetchone() if prof_row and prof_row[0]: import json as _json prof = _json.loads(prof_row[0]) if isinstance(prof_row[0], str) else prof_row[0] for col in (prof.get("columns") or []): columns.append({ "name": col.get("name"), "type": col.get("type"), "nullable": col.get("nullable", True), }) except Exception: logger.warning("could not load profile for %s", table_id) # Fallback: when table_profiles has no row (table never synced, or # profile was wiped), introspect schema via the same code path the # /api/v2/schema endpoint uses. Handles every source type — internal # via connectors.internal, BigQuery remote via the BQ extension, # local + materialized via DESCRIBE on the parquet. Best-effort — # any failure (parquet missing, BQ creds absent, etc.) leaves the # columns section in its "run a sync" empty state. if not columns: try: from app.api.v2_schema import build_schema_uncached from connectors.bigquery.access import BqAccess sch = build_schema_uncached(conn, table_id, bq=BqAccess(), row=table) for col in (sch.get("columns") or []): columns.append({ "name": col.get("name"), "type": col.get("type"), "nullable": col.get("nullable", True), }) except Exception: logger.warning("schema introspection fallback failed for %s", table_id) last_sync_state = SyncStateRepository(conn).get_table_state(table_id) or {} def _fmt_bytes(n): if n is None or n <= 0: return None for unit in ("B", "KiB", "MiB", "GiB", "TiB"): if n < 1024: return f"{n:.1f} {unit}" if unit != "B" else f"{int(n)} {unit}" n /= 1024 return f"{n:.1f} PiB" rows_count = last_sync_state.get("rows") size_bytes = ( last_sync_state.get("file_size_bytes") or last_sync_state.get("uncompressed_size_bytes") ) ctx = _build_context( request, user=user, table=table, parent_packages=parent_packages, pairs_well_with=pairs, columns=columns, last_sync_display=( str(last_sync_state.get("last_sync"))[:19] if last_sync_state.get("last_sync") else None ), rows_display=(f"{rows_count:,}" if rows_count else None), size_display=_fmt_bytes(size_bytes), sample_questions=(table.get("sample_questions") or []), things_to_know=table.get("things_to_know") or "", ) return templates.TemplateResponse(request, "catalog_table_detail.html", ctx) @router.get("/catalog/r/{slug}", response_class=HTMLResponse) async def catalog_recipe_detail( slug: str, request: Request, user: dict = Depends(get_current_user), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): """Per-recipe drill-down — title, description, SQL template, related tables. Admins see every recipe (incl. drafts); non-admins see only ``prod`` recipes their groups have a ``resource_grants`` row for. Returns 404 (not 403) so unprivileged callers can't probe for the existence of a recipe they aren't allowed to know about. """ from app.auth.access import can_access from app.resource_types import ResourceType from src.repositories.recipes import RecipesRepository from src.repositories.table_registry import TableRegistryRepository recipe = RecipesRepository(conn).get_by_slug(slug) if not recipe: raise HTTPException(status_code=404, detail="recipe_not_found") is_admin = is_user_admin(user["id"], conn) if not is_admin: if (recipe.get("status") or "prod") != "prod": raise HTTPException(status_code=404, detail="recipe_not_found") if not can_access(user["id"], ResourceType.RECIPE.value, recipe["id"], conn): raise HTTPException(status_code=404, detail="recipe_not_found") table_repo = TableRegistryRepository(conn) related_tables = [] for tid in (recipe.get("related_table_ids") or []): full = table_repo.get(tid) if full: related_tables.append({"id": full["id"], "name": full["name"]}) ctx = _build_context( request, user=user, recipe=recipe, related_tables=related_tables, ) return templates.TemplateResponse(request, "catalog_recipe_detail.html", ctx) def _human_size(n: int) -> str: """Format bytes as a short human string. Mirrors the format used on the marketplace card meta line.""" if not n: return "0 B" for unit in ("B", "KB", "MB", "GB", "TB"): if n < 1024: return f"{n:.1f} {unit}".replace(".0 ", " ") n /= 1024 return f"{n:.1f} PB" def _memory_domain_entry_dict(entry, drilldown_url: str, items_count: int = 0, required_count: int = 0) -> dict: """Adapt a ResourceEntry (memory_domain) → template entry dict. Always renders a meta line (`N items · K required` — even `0 items`) and a description fallback so seeded canonical domains without an admin-authored description don't render as half-empty cards. """ meta = f"{items_count} item{'s' if items_count != 1 else ''}" if required_count: meta += f" · {required_count} required" description = entry.description or ( f"Curated knowledge for the {entry.name} domain. " f"Add to your stack to include items in agnes pull." ) return { "id": entry.id, "name": entry.name, "description": description, "icon": entry.icon or "🎯", "color": entry.color or "#e0f2fe", # v50: see _data_package_entry_dict for the cover_image_url contract. "cover_image_url": getattr(entry, "cover_image_url", None), # v51: status surfaces as the cover-corner pill. Memory Domains # have no per-card category (the domain IS the category). "status": getattr(entry, "status", None) or "prod", "category": None, "requirement": entry.requirement, "in_stack": entry.in_stack, "meta": meta, "tags": [], "drilldown_url": drilldown_url, "footer_left": ( f"View {items_count} item{'s' if items_count != 1 else ''} →" if items_count else "Open →" ), } @router.get("/corporate-memory", response_class=HTMLResponse) async def corporate_memory( request: Request, user: dict = Depends(get_current_user), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): """Curated Memory web view — any authenticated user. v49 (Task 8.4): the top-level page is a Browse of memory domains using the shared `_stack_card.html` macro; the per-item richness (votes, contributors, tags, edit, dismiss) moves to /memory/d/ (Task 8.5). The admin review queue lives separately at /admin/corporate-memory behind require_admin. Gating matches the underlying ``/api/memory/*`` endpoints, which already run on ``get_current_user`` — CLI / agent flows that POST a knowledge item or read ``/api/memory`` work for any authenticated user, so the web view does too. Admin-only affordances on this page (the pending-review banner) stay gated server-side: ``is_admin_view`` zeroes ``pending_review_count`` for non-admins. """ from app.services.stack_resolver import StackResolver from app.resource_types import ResourceType from src.repositories.memory_domains import MemoryDomainsRepository resolver = StackResolver(conn) domains_repo = MemoryDomainsRepository(conn) repo = KnowledgeRepository(conn) # Per-domain counts (items + required) computed once and indexed by id. dom_meta: dict[str, dict] = {} try: for d in domains_repo.list(limit=10000): summaries = domains_repo.list_items_of_domain(d["id"], limit=10000) item_ids = [s["id"] for s in summaries] required = 0 if item_ids: placeholders = ",".join(["?"] * len(item_ids)) required = conn.execute( f"SELECT COUNT(*) FROM knowledge_items " f"WHERE id IN ({placeholders}) AND is_required = TRUE", item_ids, ).fetchone()[0] dom_meta[d["id"]] = { "items_count": len(summaries), "required_count": required, "slug": d["slug"], } except Exception as e: logger.warning("could not enumerate memory_domains: %s", e) is_admin_view = is_user_admin(user["id"], conn) # Admin god-mode for BROWSE only: surface every domain regardless of # group grants so admins can audit the full set. ``browse_admin`` runs # the v51 enrichment pass (status) plus v56 derived badges so admin # cards stay visually consistent with non-admin browse. For MY STACK # we still call the resolver — admins who POST /api/stack/subscribe # expect to see those subscriptions in their stack tab. if is_admin_view: browse_entries = resolver.browse_admin(user["id"], ResourceType.MEMORY_DOMAIN) stack_entries = resolver.stack(user["id"], ResourceType.MEMORY_DOMAIN) else: browse_entries = resolver.browse(user["id"], ResourceType.MEMORY_DOMAIN) stack_entries = resolver.stack(user["id"], ResourceType.MEMORY_DOMAIN) # Required-first grouping mirrors /catalog (first-demo feedback). browse_entries = sorted( browse_entries, key=lambda e: (0 if e.requirement == "required" else 1, e.name or ""), ) def _adapt(e): meta = dom_meta.get(e.id, {}) slug = meta.get("slug") return _memory_domain_entry_dict( e, drilldown_url=f"/memory/d/{slug}" if slug else f"/corporate-memory#{e.id}", items_count=meta.get("items_count", 0), required_count=meta.get("required_count", 0), ) # Hide empty domains from the user-facing browse list — a domain with # zero items has nothing for an analyst to opt-into. Admins manage # empty placeholders from /admin/corporate-memory#domains. Required # domains (items_count == 0 but still mandated) stay visible so the # mandate is honored even if the items were just deleted. def _has_content(e): meta = dom_meta.get(e.id, {}) return meta.get("items_count", 0) > 0 or e.requirement == "required" entries = [_adapt(e) for e in browse_entries if _has_content(e)] stack_entries_adapted = [_adapt(e) for e in stack_entries if _has_content(e)] # Pending banner contract (issue #176) — admin-only, counts items in # status='pending'. Kept identical to the legacy route so the page test # (test_corporate_memory_page.py) keeps passing. pending_count = 0 if is_admin_view: try: pending_count = conn.execute( "SELECT COUNT(*) FROM knowledge_items WHERE status = 'pending'" ).fetchone()[0] except Exception: pending_count = 0 ctx = _build_context( request, user=user, entries=entries, stack_entries=stack_entries_adapted, pending_review_count=pending_count, is_km_admin=is_admin_view, ) return templates.TemplateResponse(request, "corporate_memory.html", ctx) @router.get("/memory/d/{slug}", response_class=HTMLResponse) async def memory_domain_detail( slug: str, request: Request, user: dict = Depends(get_current_user), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): """Per-domain drill-down — header + per-item richness (Task 8.5). Preserves the full per-item affordance set from the legacy /corporate- memory page: votes, contributors, tags, category/source/required badges, dismiss/undismiss, mark-personal toggle, admin edit link. """ from app.auth.access import can_access from app.resource_types import ResourceType from app.services.stack_resolver import StackResolver from src.repositories.memory_domains import MemoryDomainsRepository from src.repositories.usage import UsageRepository domains_repo = MemoryDomainsRepository(conn) repo = KnowledgeRepository(conn) domain = domains_repo.get_by_slug(slug) if not domain: raise HTTPException(status_code=404, detail="memory_domain_not_found") if not (is_user_admin(user["id"], conn) or can_access(user["id"], ResourceType.MEMORY_DOMAIN.value, domain["id"], conn)): raise HTTPException(status_code=403, detail="access_denied") source_hint = request.query_params.get("source", "direct") try: UsageRepository(conn).emit_server_event( event_type="memory_domain.view", user_id=user["id"], username=user.get("email") or user["id"], props={"slug": slug, "source": source_hint}, ) except Exception: logger.warning("usage_events emit failed for memory_domain.view") resolver = StackResolver(conn) effective_required = resolver.is_required( user["id"], ResourceType.MEMORY_DOMAIN, domain["id"] ) in_stack = effective_required or bool(conn.execute( "SELECT 1 FROM user_stack_subscriptions " "WHERE user_id = ? AND resource_type = 'memory_domain' AND resource_id = ?", [user["id"], domain["id"]], ).fetchone()) # Hydrate items with votes + contributors + dismissed-by-me + tags. summaries = domains_repo.list_items_of_domain(domain["id"], limit=10000) dismissed_set = set(repo.list_dismissed_ids(user["id"])) if user.get("id") else set() items: list[dict] = [] required_count = 0 for s in summaries: it = repo.get_by_id(s["id"]) if not it: continue if it.get("is_required"): required_count += 1 votes = repo.get_votes(it["id"]) it["upvotes"] = votes["upvotes"] it["downvotes"] = votes["downvotes"] it["dismissed_by_me"] = it["id"] in dismissed_set # Contributor avatars from source_user (single contributor today). su = (it.get("source_user") or "").strip() if su: name = su.split("@", 1)[0] parts = [p for p in name.replace(".", " ").replace("_", " ").split() if p] if len(parts) >= 2: initials = (parts[0][0] + parts[1][0]).upper() elif parts: initials = parts[0][:2].upper() else: initials = name[:2].upper() it["contributors_display"] = [{"name": name, "initials": initials}] else: it["contributors_display"] = [] items.append(it) # Sort: required first, then by created_at desc (stable + predictable). items.sort(key=lambda r: (not r.get("is_required"), -((r.get("created_at") or 0).timestamp() if hasattr(r.get("created_at") or 0, "timestamp") else 0))) # Tag user with is_admin flag for template-side admin affordances. user_render = dict(user) user_render["is_admin"] = is_user_admin(user["id"], conn) ctx = _build_context( request, user=user_render, domain=domain, items=items, required_count=required_count, effective_requirement="required" if effective_required else "available", in_stack=in_stack, ) return templates.TemplateResponse(request, "memory_domain_detail.html", ctx) @router.get("/admin/corporate-memory", response_class=HTMLResponse) async def corporate_memory_admin( request: Request, user: dict = Depends(require_admin), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): """Curated Memory review queue — admin-only. The governance surface paired with the user-facing ``/corporate-memory`` page: pending items awaiting review, contradictions, duplicate candidates, and the audit trail. Reached from the Admin nav dropdown. """ 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 # admin_corporate_memory.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 # `