Complete Section 1 security/auth hardening

This commit is contained in:
dwindown
2026-04-30 11:35:56 +07:00
parent 432ffbcdb9
commit 12d2d9458f
15 changed files with 863 additions and 232 deletions

View File

@@ -20,7 +20,7 @@ from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import HTMLResponse, RedirectResponse
from starlette.status import HTTP_303_SEE_OTHER, HTTP_401_UNAUTHORIZED
from starlette.status import HTTP_303_SEE_OTHER, HTTP_401_UNAUTHORIZED, HTTP_429_TOO_MANY_REQUESTS
from app.core.config import get_settings
from app.database import get_db
@@ -53,9 +53,13 @@ settings = get_settings()
router = APIRouter(prefix="/admin", tags=["admin-web"])
SESSION_COOKIE = "access_token"
CSRF_COOKIE = "admin_csrf_token"
SESSION_PREFIX = "admin:session:"
IMPORT_PREVIEW_PREFIX = "admin:import-preview:"
IMPORT_PREVIEW_TTL_SECONDS = 900
LOGIN_RATE_LIMIT_PREFIX = "admin:login:attempts:"
LOGIN_RATE_LIMIT_MAX_ATTEMPTS = 10
LOGIN_RATE_LIMIT_WINDOW_SECONDS = 300
_admin_redis = None
@@ -153,10 +157,27 @@ def _render_auth_page(
</main>
</body>
</html>"""
return HTMLResponse(html, status_code=status_code)
csrf_token = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24)
csrf_input = f'<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">'
html = re.sub(
r'(<form[^>]*method="post"[^>]*>)',
r"\1" + csrf_input,
html,
flags=re.IGNORECASE,
)
response = HTMLResponse(html, status_code=status_code)
response.set_cookie(
CSRF_COOKIE,
csrf_token,
path="/admin",
httponly=False,
secure=settings.ENVIRONMENT == "production",
samesite="lax",
)
return response
def _render_admin_page(title: str, page_title: str, body: str) -> HTMLResponse:
def _render_admin_page(request: Request, title: str, page_title: str, body: str) -> HTMLResponse:
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
@@ -213,7 +234,46 @@ def _render_admin_page(title: str, page_title: str, body: str) -> HTMLResponse:
</div>
</body>
</html>"""
return HTMLResponse(html)
csrf_token = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24)
csrf_input = f'<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">'
html = re.sub(
r'(<form[^>]*method="post"[^>]*>)',
r"\1" + csrf_input,
html,
flags=re.IGNORECASE,
)
response = HTMLResponse(html)
response.set_cookie(
CSRF_COOKIE,
csrf_token,
path="/admin",
httponly=False,
secure=settings.ENVIRONMENT == "production",
samesite="lax",
)
return response
def _verify_csrf(request: Request, csrf_token: str | None) -> None:
cookie_token = request.cookies.get(CSRF_COOKIE)
if not cookie_token or not csrf_token:
raise HTTPException(status_code=403, detail="CSRF validation failed")
if not secrets.compare_digest(cookie_token, csrf_token):
raise HTTPException(status_code=403, detail="CSRF validation failed")
async def _enforce_csrf(request: Request) -> None:
form = await request.form()
_verify_csrf(request, form.get("csrf_token"))
async def _csrf_route_guard(request: Request) -> None:
if request.method.upper() != "POST":
return
await _enforce_csrf(request)
router.dependencies.append(Depends(_csrf_route_guard))
def _table(headers: list[str], rows: list[list[Any]]) -> str:
@@ -1052,10 +1112,58 @@ async def login_submit(
password: str = Form(...),
remember_me: str | None = Form(None),
):
if _admin_redis is None:
body = """
<div class="error">Admin backend is temporarily unavailable. Please try again.</div>
<form method="post" action="/admin/login" autocomplete="off">
<label for="username">Username</label>
<input id="username" name="username" type="text" autocomplete="username">
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password">
<label class="row"><input type="checkbox" name="remember_me" __REMEMBER_ME_CHECKED__> Remember me</label>
<button type="submit">Sign in</button>
</form>
"""
return _render_auth_page(
request,
"Admin Login",
"Use the configured admin credentials to access the dashboard.",
body,
status_code=503,
)
client_ip = request.client.host if request.client else "unknown"
rate_limit_key = f"{LOGIN_RATE_LIMIT_PREFIX}{client_ip}"
attempts_raw = await _admin_redis.get(rate_limit_key)
attempts = int(attempts_raw) if attempts_raw else 0
if attempts >= LOGIN_RATE_LIMIT_MAX_ATTEMPTS:
body = """
<div class="error">Too many login attempts. Please wait a few minutes and try again.</div>
<form method="post" action="/admin/login" autocomplete="off">
<label for="username">Username</label>
<input id="username" name="username" type="text" autocomplete="username">
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password">
<label class="row"><input type="checkbox" name="remember_me" __REMEMBER_ME_CHECKED__> Remember me</label>
<button type="submit">Sign in</button>
</form>
"""
return _render_auth_page(
request,
"Admin Login",
"Use the configured admin credentials to access the dashboard.",
body,
status_code=HTTP_429_TOO_MANY_REQUESTS,
)
if not (
secrets.compare_digest(username, settings.ADMIN_USERNAME)
and secrets.compare_digest(password, settings.ADMIN_PASSWORD)
):
attempts = await _admin_redis.incr(rate_limit_key)
if attempts == 1:
await _admin_redis.expire(rate_limit_key, LOGIN_RATE_LIMIT_WINDOW_SECONDS)
body = f"""
<div class="error">Invalid username or password.</div>
<form method="post" action="/admin/login" autocomplete="off">
@@ -1075,11 +1183,21 @@ async def login_submit(
status_code=HTTP_401_UNAUTHORIZED,
)
await _admin_redis.delete(rate_limit_key)
expire = settings.ADMIN_SESSION_EXPIRE_SECONDS
response = _dashboard_redirect()
secure_cookie = settings.ENVIRONMENT == "production"
if remember_me == "on":
expire = max(expire, 3600 * 24 * 30)
response.set_cookie("remember_me", "on", expires=expire, path="/admin")
response.set_cookie(
"remember_me",
"on",
expires=expire,
path="/admin",
secure=secure_cookie,
samesite="lax",
)
else:
response.delete_cookie("remember_me", path="/admin")
@@ -1090,6 +1208,7 @@ async def login_submit(
expires=expire,
path="/admin",
httponly=True,
secure=secure_cookie,
samesite="lax",
)
await _admin_redis.set(f"{SESSION_PREFIX}{token}", settings.ADMIN_USERNAME, ex=expire)
@@ -1179,7 +1298,7 @@ async def dashboard_view(request: Request, db: AsyncSession = Depends(get_db)):
</div>
<p style="margin-top:20px"><a href="/admin/ai-playground">Open AI Playground</a></p>
"""
return _render_admin_page("IRT Bank Soal Admin", "Dashboard", body)
return _render_admin_page(request, "IRT Bank Soal Admin", "Dashboard", body)
@router.get("/websites", include_in_schema=False)
@@ -1191,7 +1310,7 @@ async def websites_view(request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Website).order_by(Website.id.asc()))
websites = list(result.scalars().all())
body = _websites_form_body(websites)
return _render_admin_page("Websites", "Websites", body)
return _render_admin_page(request, "Websites", "Websites", body)
@router.post("/websites", include_in_schema=False)
@@ -1217,7 +1336,7 @@ async def websites_submit(
site_name=site_name,
site_url=site_url,
)
return _render_admin_page("Websites", "Websites", body)
return _render_admin_page(request, "Websites", "Websites", body)
if not normalized_url.startswith(("http://", "https://")):
result = await db.execute(select(Website).order_by(Website.id.asc()))
@@ -1228,7 +1347,7 @@ async def websites_submit(
site_name=site_name,
site_url=site_url,
)
return _render_admin_page("Websites", "Websites", body)
return _render_admin_page(request, "Websites", "Websites", body)
website = Website(site_name=normalized_name, site_url=normalized_url)
db.add(website)
@@ -1244,7 +1363,7 @@ async def websites_submit(
site_name=site_name,
site_url=site_url,
)
return _render_admin_page("Websites", "Websites", body)
return _render_admin_page(request, "Websites", "Websites", body)
result = await db.execute(select(Website).order_by(Website.id.asc()))
websites = list(result.scalars().all())
@@ -1252,7 +1371,7 @@ async def websites_submit(
websites,
success=f"Website added successfully with ID {website.id}.",
)
return _render_admin_page("Websites", "Websites", body)
return _render_admin_page(request, "Websites", "Websites", body)
@router.get("/websites/{website_id}/edit", include_in_schema=False)
@@ -1270,10 +1389,10 @@ async def website_edit_view(
result = await db.execute(select(Website).order_by(Website.id.asc()))
websites = list(result.scalars().all())
body = _websites_form_body(websites, error=f"Website not found: {website_id}")
return _render_admin_page("Websites", "Websites", body)
return _render_admin_page(request, "Websites", "Websites", body)
body = _website_edit_form_body(website)
return _render_admin_page("Edit Website", "Edit Website", body)
return _render_admin_page(request, "Edit Website", "Edit Website", body)
@router.post("/websites/{website_id}/edit", include_in_schema=False)
@@ -1293,7 +1412,7 @@ async def website_edit_submit(
result = await db.execute(select(Website).order_by(Website.id.asc()))
websites = list(result.scalars().all())
body = _websites_form_body(websites, error=f"Website not found: {website_id}")
return _render_admin_page("Websites", "Websites", body)
return _render_admin_page(request, "Websites", "Websites", body)
normalized_name = site_name.strip()
normalized_url = site_url.strip().rstrip("/")
@@ -1305,7 +1424,7 @@ async def website_edit_submit(
site_name=site_name,
site_url=site_url,
)
return _render_admin_page("Edit Website", "Edit Website", body)
return _render_admin_page(request, "Edit Website", "Edit Website", body)
if not normalized_url.startswith(("http://", "https://")):
body = _website_edit_form_body(
@@ -1314,7 +1433,7 @@ async def website_edit_submit(
site_name=site_name,
site_url=site_url,
)
return _render_admin_page("Edit Website", "Edit Website", body)
return _render_admin_page(request, "Edit Website", "Edit Website", body)
website.site_name = normalized_name
website.site_url = normalized_url
@@ -1328,14 +1447,14 @@ async def website_edit_submit(
site_name=site_name,
site_url=site_url,
)
return _render_admin_page("Edit Website", "Edit Website", body)
return _render_admin_page(request, "Edit Website", "Edit Website", body)
await db.refresh(website)
body = _website_edit_form_body(
website,
success=f"Website #{website.id} updated successfully.",
)
return _render_admin_page("Edit Website", "Edit Website", body)
return _render_admin_page(request, "Edit Website", "Edit Website", body)
@router.post("/websites/{website_id}/delete", include_in_schema=False)
@@ -1353,7 +1472,7 @@ async def website_delete_submit(
result = await db.execute(select(Website).order_by(Website.id.asc()))
websites = list(result.scalars().all())
body = _websites_form_body(websites, error=f"Website not found: {website_id}")
return _render_admin_page("Websites", "Websites", body)
return _render_admin_page(request, "Websites", "Websites", body)
deleted_label = f"{website.site_name} ({website.site_url})"
await db.delete(website)
@@ -1365,7 +1484,7 @@ async def website_delete_submit(
websites,
success=f"Website deleted successfully: {deleted_label}",
)
return _render_admin_page("Websites", "Websites", body)
return _render_admin_page(request, "Websites", "Websites", body)
@router.get("/tryout-import", include_in_schema=False)
@@ -1377,7 +1496,7 @@ async def tryout_import_view(request: Request, db: AsyncSession = Depends(get_db
websites = await _load_websites(db)
snapshots = await _recent_snapshots(db)
body = _tryout_import_form_body(websites, snapshots)
return _render_admin_page("Tryout Import", "Tryout Import", body)
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
@router.post("/tryout-import/preview", include_in_schema=False)
@@ -1401,7 +1520,7 @@ async def tryout_import_preview(
error="File must be .json format.",
selected_website_id=website_id,
)
return _render_admin_page("Tryout Import", "Tryout Import", body)
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
try:
payload_bytes = await file.read()
@@ -1414,7 +1533,7 @@ async def tryout_import_preview(
error="File must be UTF-8 encoded JSON.",
selected_website_id=website_id,
)
return _render_admin_page("Tryout Import", "Tryout Import", body)
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
except json.JSONDecodeError as exc:
body = _tryout_import_form_body(
websites,
@@ -1422,7 +1541,7 @@ async def tryout_import_preview(
error=f"Invalid JSON file: {exc}",
selected_website_id=website_id,
)
return _render_admin_page("Tryout Import", "Tryout Import", body)
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
try:
preview = await preview_tryout_json_import(payload, website_id, db)
@@ -1433,7 +1552,7 @@ async def tryout_import_preview(
error=str(exc),
selected_website_id=website_id,
)
return _render_admin_page("Tryout Import", "Tryout Import", body)
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
preview_token = uuid.uuid4().hex
await _admin_redis.set(
@@ -1449,7 +1568,7 @@ async def tryout_import_preview(
preview_token=preview_token,
upload_filename=file.filename or "",
)
return _render_admin_page("Tryout Import", "Tryout Import", body)
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
@router.post("/tryout-import", include_in_schema=False)
@@ -1474,7 +1593,7 @@ async def tryout_import_submit(
error="Preview token expired. Upload the JSON again and preview before importing.",
selected_website_id=website_id,
)
return _render_admin_page("Tryout Import", "Tryout Import", body)
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
try:
payload = json.loads(payload_text)
@@ -1488,7 +1607,7 @@ async def tryout_import_submit(
error=str(exc),
selected_website_id=website_id,
)
return _render_admin_page("Tryout Import", "Tryout Import", body)
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
except Exception:
await db.rollback()
raise
@@ -1506,7 +1625,7 @@ async def tryout_import_submit(
),
selected_website_id=website_id,
)
return _render_admin_page("Tryout Import", "Tryout Import", body)
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
@router.get("/snapshot-questions", include_in_schema=False)
@@ -1528,11 +1647,11 @@ async def snapshot_questions_view(
snapshots,
error=f"Snapshot not found: {snapshot_id}",
)
return _render_admin_page("Tryout Import", "Tryout Import", body)
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
questions, promoted_items_by_slot, _ = await _load_snapshot_question_context(snapshot, db)
body = _snapshot_questions_body(snapshot, questions, promoted_items_by_slot)
return _render_admin_page("Snapshot Questions", "Snapshot Questions", body)
return _render_admin_page(request, "Snapshot Questions", "Snapshot Questions", body)
@router.post("/snapshot-questions/promote-bulk", include_in_schema=False)
@@ -1555,7 +1674,7 @@ async def snapshot_question_promote_bulk(
snapshots,
error=f"Snapshot not found: {snapshot_id}",
)
return _render_admin_page("Tryout Import", "Tryout Import", body)
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
if not snapshot_question_ids:
questions, promoted_items_by_slot, _ = await _load_snapshot_question_context(snapshot, db)
@@ -1565,7 +1684,7 @@ async def snapshot_question_promote_bulk(
promoted_items_by_slot,
error="Select at least one snapshot question to promote.",
)
return _render_admin_page("Snapshot Questions", "Snapshot Questions", body)
return _render_admin_page(request, "Snapshot Questions", "Snapshot Questions", body)
question_result = await db.execute(
select(TryoutSnapshotQuestion).where(
@@ -1607,7 +1726,7 @@ async def snapshot_question_promote_bulk(
success_message += f" Latest basis item ID: {created_items[-1].id}."
body = _snapshot_questions_body(snapshot, questions, promoted_items_by_slot, success=success_message)
return _render_admin_page("Snapshot Questions", "Snapshot Questions", body)
return _render_admin_page(request, "Snapshot Questions", "Snapshot Questions", body)
@router.get("/calibration-status", include_in_schema=False)
@@ -1637,7 +1756,7 @@ async def calibration_status_view(request: Request, db: AsyncSession = Depends(g
["Tryout ID", "Name", "Total Items", "Calibrated", "Calibration %", "Ready for IRT"],
rows,
)
return _render_admin_page("Calibration Status", "Calibration Status", body)
return _render_admin_page(request, "Calibration Status", "Calibration Status", body)
@router.get("/item-statistics", include_in_schema=False)
@@ -1672,7 +1791,7 @@ async def item_statistics_view(request: Request, db: AsyncSession = Depends(get_
["Level", "Total Items", "Calibrated", "Calibration %", "Responses", "Avg Correctness"],
rows,
)
return _render_admin_page("Item Statistics", "Item Statistics", body)
return _render_admin_page(request, "Item Statistics", "Item Statistics", body)
@router.get("/session-overview", include_in_schema=False)
@@ -1702,7 +1821,7 @@ async def session_overview_view(request: Request, db: AsyncSession = Depends(get
["Session ID", "WP User", "Tryout", "Completed", "Mode", "Benar", "NM", "NN", "Theta"],
rows,
)
return _render_admin_page("Session Overview", "Session Overview", body)
return _render_admin_page(request, "Session Overview", "Session Overview", body)
@router.get("/basis-items", include_in_schema=False)
@@ -1719,7 +1838,7 @@ async def basis_items_view(request: Request, db: AsyncSession = Depends(get_db))
)
basis_items = list(result.scalars().all())
body = _basis_items_list_body(basis_items)
return _render_admin_page("Basis Items", "Basis Items", body)
return _render_admin_page(request, "Basis Items", "Basis Items", body)
@router.get("/basis-items/{basis_item_id}", include_in_schema=False)
@@ -1752,7 +1871,7 @@ async def basis_item_workspace_view(
.limit(200)
)
body = _basis_items_list_body(list(result.scalars().all()))
return _render_admin_page("Basis Items", "Basis Items", body)
return _render_admin_page(request, "Basis Items", "Basis Items", body)
run_result = await db.execute(
select(AIGenerationRun)
@@ -1794,7 +1913,7 @@ async def basis_item_workspace_view(
family_stats,
filters,
)
return _render_admin_page(
return _render_admin_page(request,
f"Basis Item #{basis_item.id}",
f"Basis Item Workspace #{basis_item.id}",
body,
@@ -1856,7 +1975,7 @@ async def basis_item_generate_submit(
include_note_for_admin=note_for_admin,
include_note_in_prompt=note_in_prompt,
)
return _render_admin_page(
return _render_admin_page(request,
f"Basis Item #{basis_item.id}",
f"Basis Item Workspace #{basis_item.id}",
body,
@@ -1951,7 +2070,7 @@ async def basis_item_generate_submit(
include_note_for_admin=note_for_admin,
include_note_in_prompt=note_in_prompt,
)
return _render_admin_page(
return _render_admin_page(request,
f"Basis Item #{basis_item.id}",
f"Basis Item Workspace #{basis_item.id}",
body,
@@ -2016,7 +2135,7 @@ async def basis_item_review_bulk(
filters,
success=f"Applied status '{action}' to selected variants.",
)
return _render_admin_page(
return _render_admin_page(request,
f"Basis Item #{basis_item.id}",
f"Basis Item Workspace #{basis_item.id}",
body,
@@ -2202,7 +2321,7 @@ async def ai_playground_view(request: Request, db: AsyncSession = Depends(get_db
generated_variants=generated_variants,
basis_item_id=str(basis_item_id or ""),
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
@router.post("/ai-playground/seed-demo", include_in_schema=False)
@@ -2225,7 +2344,7 @@ async def ai_playground_seed_demo(request: Request, db: AsyncSession = Depends(g
generated_variants=generated_variants,
basis_item_id=str(demo_item.id),
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
@router.post("/ai-playground", include_in_schema=False)
@@ -2268,7 +2387,7 @@ async def ai_playground_submit(
include_note_for_admin=note_for_admin,
include_note_in_prompt=note_in_prompt,
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
if target_level not in {"mudah", "sulit"}:
body = _ai_form_body(
@@ -2286,7 +2405,7 @@ async def ai_playground_submit(
include_note_for_admin=note_for_admin,
include_note_in_prompt=note_in_prompt,
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
if not validate_ai_model(ai_model):
body = _ai_form_body(
@@ -2304,7 +2423,7 @@ async def ai_playground_submit(
include_note_for_admin=note_for_admin,
include_note_in_prompt=note_in_prompt,
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
result = await db.execute(select(Item).where(Item.id == basis_item_id))
basis_item = result.scalar_one_or_none()
@@ -2324,7 +2443,7 @@ async def ai_playground_submit(
include_note_for_admin=note_for_admin,
include_note_in_prompt=note_in_prompt,
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
if basis_item.level != "sedang":
body = _ai_form_body(
@@ -2342,7 +2461,7 @@ async def ai_playground_submit(
include_note_for_admin=note_for_admin,
include_note_in_prompt=note_in_prompt,
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
if generation_count < 1 or generation_count > 50:
body = _ai_form_body(
@@ -2360,7 +2479,7 @@ async def ai_playground_submit(
include_note_for_admin=note_for_admin,
include_note_in_prompt=note_in_prompt,
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
run_id = await create_generation_run(
basis_item_id=basis_item.id,
@@ -2428,7 +2547,7 @@ async def ai_playground_submit(
include_note_for_admin=note_for_admin,
include_note_in_prompt=note_in_prompt,
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
body = _ai_form_body(
True,
@@ -2451,7 +2570,7 @@ async def ai_playground_submit(
include_note_for_admin=note_for_admin,
include_note_in_prompt=note_in_prompt,
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
@router.post("/ai-playground/save", include_in_schema=False)
@@ -2483,7 +2602,7 @@ async def ai_playground_save(
error="Only mudah or sulit generated items can be saved from the playground.",
basis_items=basis_items,
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
try:
options = json.loads(options_json)
@@ -2494,7 +2613,7 @@ async def ai_playground_save(
error="Generated options payload is invalid.",
basis_items=basis_items,
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
from app.schemas.ai import GeneratedQuestion
@@ -2521,7 +2640,7 @@ async def ai_playground_save(
error="Failed to save generated item. Check server logs for the database error.",
basis_items=basis_items,
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
await db.commit()
updated_stats = await get_ai_stats(db)
@@ -2535,7 +2654,7 @@ async def ai_playground_save(
target_level=target_level,
ai_model=ai_model,
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
@router.post("/ai-playground/review-bulk", include_in_schema=False)
@@ -2564,7 +2683,7 @@ async def ai_playground_review_bulk(
generation_runs=generation_runs,
generated_variants=generated_variants,
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
if not item_ids:
body = _ai_form_body(
@@ -2575,7 +2694,7 @@ async def ai_playground_review_bulk(
generation_runs=generation_runs,
generated_variants=generated_variants,
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
result = await db.execute(
select(Item).where(Item.id.in_(item_ids), Item.generated_by == "ai")
@@ -2590,7 +2709,7 @@ async def ai_playground_review_bulk(
generation_runs=generation_runs,
generated_variants=generated_variants,
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
reviewed_at = datetime.now(timezone.utc)
for item in items:
@@ -2612,7 +2731,7 @@ async def ai_playground_review_bulk(
generation_runs=updated_runs,
generated_variants=updated_variants,
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
@router.get("/tryout/list", include_in_schema=False)