feat(api): /api/welcome + /api/admin/welcome-template endpoints

This commit is contained in:
ZdenekSrotyr 2026-04-30 19:00:01 +02:00
parent 4449623af8
commit 0d1ecd235d
3 changed files with 174 additions and 0 deletions

92
app/api/welcome.py Normal file
View file

@ -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)

View file

@ -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_sample import router as v2_sample_router
from app.api.v2_scan import router as v2_scan_router from app.api.v2_scan import router as v2_scan_router
from app.api.marketplaces import router as marketplaces_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.router import router as marketplace_server_router
from app.marketplace_server.git_router import make_git_wsgi_app from app.marketplace_server.git_router import make_git_wsgi_app
from app.web.router import router as web_router 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_sample_router)
app.include_router(v2_scan_router) app.include_router(v2_scan_router)
app.include_router(marketplaces_router) app.include_router(marketplaces_router)
app.include_router(welcome_router)
app.include_router(marketplace_server_router) app.include_router(marketplace_server_router)
# Git smart-HTTP endpoint for Claude Code: /marketplace.git/* # Git smart-HTTP endpoint for Claude Code: /marketplace.git/*

View file

@ -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()