From 0d1ecd235dcaa5ce26048f9b4d2d19dc273306f9 Mon Sep 17 00:00:00 2001 From: ZdenekSrotyr Date: Thu, 30 Apr 2026 19:00:01 +0200 Subject: [PATCH] feat(api): /api/welcome + /api/admin/welcome-template endpoints --- app/api/welcome.py | 92 ++++++++++++++++++++++++++++++ app/main.py | 2 + tests/test_welcome_template_api.py | 80 ++++++++++++++++++++++++++ 3 files changed, 174 insertions(+) create mode 100644 app/api/welcome.py create mode 100644 tests/test_welcome_template_api.py diff --git a/app/api/welcome.py b/app/api/welcome.py new file mode 100644 index 0000000..97e035d --- /dev/null +++ b/app/api/welcome.py @@ -0,0 +1,92 @@ +"""REST endpoints for the analyst-onboarding welcome prompt. + +- GET /api/welcome : render for the calling user (auth required) +- GET /api/admin/welcome-template : raw template + shipped default (admin) +- PUT /api/admin/welcome-template : set override (admin) +- DELETE /api/admin/welcome-template : reset to default (admin) +""" + +from typing import Optional + +import duckdb +from fastapi import APIRouter, Depends, HTTPException, Query, Response +from jinja2 import TemplateSyntaxError +from pydantic import BaseModel, Field + +from app.auth.access import require_admin +from app.auth.dependencies import _get_db, get_current_user +from src.repositories.welcome_template import WelcomeTemplateRepository +from src.welcome_template import _load_default_template, render_welcome + + +router = APIRouter(tags=["welcome"]) + + +class WelcomeResponse(BaseModel): + content: str + + +class TemplateGetResponse(BaseModel): + content: Optional[str] + default: str + updated_at: Optional[str] = None + updated_by: Optional[str] = None + + +class TemplatePutRequest(BaseModel): + content: str = Field(..., min_length=1, max_length=200_000) + + +@router.get("/api/welcome", response_model=WelcomeResponse) +async def get_welcome( + server_url: str = Query(..., description="The server URL the analyst is bootstrapping against"), + user: dict = Depends(get_current_user), + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + """Render the welcome prompt for the calling user. Returns rendered markdown.""" + try: + rendered = render_welcome(conn, user=user, server_url=server_url) + except TemplateSyntaxError as e: + raise HTTPException( + status_code=500, + detail=f"Welcome template has a syntax error: {e.message}. Reset via /admin/welcome.", + ) + return WelcomeResponse(content=rendered) + + +@router.get("/api/admin/welcome-template", response_model=TemplateGetResponse) +async def admin_get_template( + user: dict = Depends(require_admin), + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + row = WelcomeTemplateRepository(conn).get() + return TemplateGetResponse( + content=row["content"], + default=_load_default_template(), + updated_at=row["updated_at"].isoformat() if row["updated_at"] else None, + updated_by=row["updated_by"], + ) + + +@router.put("/api/admin/welcome-template") +async def admin_put_template( + payload: TemplatePutRequest, + user: dict = Depends(require_admin), + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + from jinja2 import Environment, StrictUndefined + try: + Environment(undefined=StrictUndefined).parse(payload.content) + except TemplateSyntaxError as e: + raise HTTPException(status_code=400, detail=f"Jinja2 syntax error: {e.message}") + WelcomeTemplateRepository(conn).set(payload.content, updated_by=user["email"]) + return {"status": "ok"} + + +@router.delete("/api/admin/welcome-template", status_code=204) +async def admin_reset_template( + user: dict = Depends(require_admin), + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + WelcomeTemplateRepository(conn).reset(updated_by=user["email"]) + return Response(status_code=204) diff --git a/app/main.py b/app/main.py index c757aaa..296135b 100644 --- a/app/main.py +++ b/app/main.py @@ -121,6 +121,7 @@ from app.api.v2_schema import router as v2_schema_router from app.api.v2_sample import router as v2_sample_router from app.api.v2_scan import router as v2_scan_router from app.api.marketplaces import router as marketplaces_router +from app.api.welcome import router as welcome_router from app.marketplace_server.router import router as marketplace_server_router from app.marketplace_server.git_router import make_git_wsgi_app from app.web.router import router as web_router @@ -527,6 +528,7 @@ def create_app() -> FastAPI: app.include_router(v2_sample_router) app.include_router(v2_scan_router) app.include_router(marketplaces_router) + app.include_router(welcome_router) app.include_router(marketplace_server_router) # Git smart-HTTP endpoint for Claude Code: /marketplace.git/* diff --git a/tests/test_welcome_template_api.py b/tests/test_welcome_template_api.py new file mode 100644 index 0000000..f4031c7 --- /dev/null +++ b/tests/test_welcome_template_api.py @@ -0,0 +1,80 @@ +"""End-to-end tests for /api/welcome and /api/admin/welcome-template.""" + + +def _auth(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def test_get_welcome_returns_rendered_markdown(seeded_app): + c = seeded_app["client"] + token = seeded_app["analyst_token"] + resp = c.get( + "/api/welcome", + params={"server_url": "https://example.com"}, + headers=_auth(token), + ) + assert resp.status_code == 200 + body = resp.json() + assert "content" in body + assert "AI Data Analyst" in body["content"] + assert "https://example.com" in body["content"] + + +def test_get_welcome_requires_auth(seeded_app): + c = seeded_app["client"] + resp = c.get("/api/welcome", params={"server_url": "https://example.com"}) + assert resp.status_code == 401 + + +def test_admin_can_set_and_reset_template(seeded_app): + c = seeded_app["client"] + admin = _auth(seeded_app["admin_token"]) + + # GET initial state + r = c.get("/api/admin/welcome-template", headers=admin) + assert r.status_code == 200 + body = r.json() + assert body["content"] is None + # The shipped default starts with the Jinja2 comment block. + assert body["default"].startswith("{#") + + # PUT override + r = c.put( + "/api/admin/welcome-template", + json={"content": "Hello {{ user.email }}"}, + headers=admin, + ) + assert r.status_code == 200 + + # Verify rendered output uses override + r = c.get( + "/api/welcome", + params={"server_url": "https://example.com"}, + headers=admin, # admin user can also call /api/welcome + ) + assert r.json()["content"].startswith("Hello ") + + # DELETE = reset + r = c.delete("/api/admin/welcome-template", headers=admin) + assert r.status_code == 204 + r = c.get("/api/admin/welcome-template", headers=admin) + assert r.json()["content"] is None + + +def test_non_admin_cannot_edit_template(seeded_app): + c = seeded_app["client"] + analyst = _auth(seeded_app["analyst_token"]) + r = c.put("/api/admin/welcome-template", json={"content": "x"}, headers=analyst) + assert r.status_code == 403 + + +def test_invalid_jinja2_returns_400(seeded_app): + c = seeded_app["client"] + admin = _auth(seeded_app["admin_token"]) + r = c.put( + "/api/admin/welcome-template", + json={"content": "{% for x in y %}"}, # unclosed loop + headers=admin, + ) + assert r.status_code == 400 + assert "syntax" in r.json()["detail"].lower()