fix(admin-welcome): credentials: include, real-content preview, refresh after mutate
This commit is contained in:
parent
2b3048f77f
commit
ecaa113c68
3 changed files with 111 additions and 14 deletions
|
|
@ -10,7 +10,7 @@ import datetime
|
|||
from typing import Optional
|
||||
|
||||
import duckdb
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
from jinja2 import Environment, StrictUndefined, TemplateError, TemplateSyntaxError
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
|
@ -61,6 +61,10 @@ class TemplatePutRequest(BaseModel):
|
|||
content: str = Field(..., min_length=1, max_length=200_000)
|
||||
|
||||
|
||||
class TemplatePreviewRequest(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"),
|
||||
|
|
@ -117,3 +121,25 @@ async def admin_reset_template(
|
|||
):
|
||||
WelcomeTemplateRepository(conn).reset(updated_by=user["email"])
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.post("/api/admin/welcome-template/preview", response_model=WelcomeResponse)
|
||||
async def admin_preview_template(
|
||||
payload: TemplatePreviewRequest,
|
||||
request: Request,
|
||||
user: dict = Depends(require_admin),
|
||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||
):
|
||||
"""Render arbitrary template content against the live context for the
|
||||
calling admin, without persisting. Used by the /admin/welcome editor's
|
||||
Preview button so admins can see their edits before saving."""
|
||||
from src.welcome_template import build_context
|
||||
|
||||
env = Environment(undefined=StrictUndefined, autoescape=False)
|
||||
try:
|
||||
template = env.from_string(payload.content)
|
||||
ctx = build_context(conn, user=user, server_url=str(request.base_url).rstrip("/"))
|
||||
rendered = template.render(**ctx)
|
||||
except TemplateError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Template invalid: {e}")
|
||||
return WelcomeResponse(content=rendered)
|
||||
|
|
|
|||
|
|
@ -10,14 +10,14 @@
|
|||
to use the OSS-shipped default.
|
||||
</p>
|
||||
|
||||
<p class="status" id="status-line">
|
||||
{% if is_override %}
|
||||
<p class="status">
|
||||
Overridden by <strong>{{ updated_by }}</strong> on
|
||||
{{ updated_at.strftime("%Y-%m-%d %H:%M UTC") if updated_at else "—" }}.
|
||||
</p>
|
||||
Overridden by <strong>{{ updated_by }}</strong> on
|
||||
{{ updated_at.strftime("%Y-%m-%d %H:%M UTC") if updated_at else "—" }}.
|
||||
{% else %}
|
||||
<p class="status">Using shipped default.</p>
|
||||
Using shipped default.
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<h2>Available placeholders</h2>
|
||||
<pre class="placeholder-cheatsheet">
|
||||
|
|
@ -50,40 +50,75 @@
|
|||
const $ = (id) => document.getElementById(id);
|
||||
const result = $("result");
|
||||
|
||||
async function refreshStatus() {
|
||||
const r = await fetch("/api/admin/welcome-template", {credentials: "include"});
|
||||
if (!r.ok) return;
|
||||
const data = await r.json();
|
||||
const status = $("status-line");
|
||||
if (data.content !== null) {
|
||||
const when = data.updated_at
|
||||
? new Date(data.updated_at).toISOString().slice(0, 16).replace("T", " ") + " UTC"
|
||||
: "—";
|
||||
status.innerHTML = "Overridden by <strong>" + (data.updated_by || "—") + "</strong> on " + when + ".";
|
||||
$("content").value = data.content;
|
||||
} else {
|
||||
status.textContent = "Using shipped default.";
|
||||
$("content").value = data.default;
|
||||
}
|
||||
}
|
||||
|
||||
$("save-btn").addEventListener("click", async () => {
|
||||
result.textContent = "Saving…";
|
||||
const r = await fetch("/api/admin/welcome-template", {
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({content: $("content").value}),
|
||||
});
|
||||
if (r.ok) {
|
||||
result.textContent = "Saved.";
|
||||
await refreshStatus();
|
||||
} else {
|
||||
const err = await r.json();
|
||||
result.textContent = "Error: " + (err.detail || r.statusText);
|
||||
let detail = r.statusText;
|
||||
try { detail = (await r.json()).detail || detail; } catch {}
|
||||
result.textContent = "Error: " + detail;
|
||||
}
|
||||
});
|
||||
|
||||
$("reset-btn").addEventListener("click", async () => {
|
||||
if (!confirm("Reset to OSS default? Your override will be lost.")) return;
|
||||
const r = await fetch("/api/admin/welcome-template", {method: "DELETE"});
|
||||
const r = await fetch("/api/admin/welcome-template", {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
if (r.ok) {
|
||||
result.textContent = "Reset. Reload to see the default.";
|
||||
result.textContent = "Reset to default.";
|
||||
await refreshStatus();
|
||||
} else {
|
||||
result.textContent = "Error: " + r.statusText;
|
||||
let detail = r.statusText;
|
||||
try { detail = (await r.json()).detail || detail; } catch {}
|
||||
result.textContent = "Error: " + detail;
|
||||
}
|
||||
});
|
||||
|
||||
$("preview-btn").addEventListener("click", async () => {
|
||||
const r = await fetch("/api/welcome?server_url=" + encodeURIComponent(window.location.origin));
|
||||
result.textContent = "Rendering preview…";
|
||||
const r = await fetch("/api/admin/welcome-template/preview", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({content: $("content").value}),
|
||||
});
|
||||
if (r.ok) {
|
||||
const j = await r.json();
|
||||
$("preview").textContent = j.content;
|
||||
$("preview").hidden = false;
|
||||
result.textContent = "Preview rendered.";
|
||||
} else {
|
||||
const err = await r.json();
|
||||
result.textContent = "Render error: " + (err.detail || r.statusText);
|
||||
let detail = r.statusText;
|
||||
try { detail = (await r.json()).detail || detail; } catch {}
|
||||
result.textContent = "Render error: " + detail;
|
||||
$("preview").hidden = true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -119,3 +119,39 @@ def test_get_welcome_500_includes_reset_hint_on_render_failure(seeded_app, monke
|
|||
)
|
||||
assert r.status_code == 500
|
||||
assert "/admin/welcome" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_admin_preview_renders_arbitrary_content(seeded_app):
|
||||
"""Preview endpoint must render the supplied content (not whatever's
|
||||
stored), so the admin UI can show pre-save preview."""
|
||||
c = seeded_app["client"]
|
||||
admin = _auth(seeded_app["admin_token"])
|
||||
r = c.post(
|
||||
"/api/admin/welcome-template/preview",
|
||||
json={"content": "# Preview {{ user.email }}"},
|
||||
headers=admin,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["content"].startswith("# Preview admin@test.com")
|
||||
|
||||
|
||||
def test_preview_rejects_invalid_template(seeded_app):
|
||||
c = seeded_app["client"]
|
||||
admin = _auth(seeded_app["admin_token"])
|
||||
r = c.post(
|
||||
"/api/admin/welcome-template/preview",
|
||||
json={"content": "{% for x in y %}"},
|
||||
headers=admin,
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_preview_requires_admin(seeded_app):
|
||||
c = seeded_app["client"]
|
||||
analyst = _auth(seeded_app["analyst_token"])
|
||||
r = c.post(
|
||||
"/api/admin/welcome-template/preview",
|
||||
json={"content": "# x"},
|
||||
headers=analyst,
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
|
|
|||
Loading…
Reference in a new issue