{escape(title)}
{escape(subtitle)}
{body.replace("__REMEMBER_ME_CHECKED__", remember_me_checked)}""" Plain FastAPI admin UI backed by SQLAlchemy and Redis sessions. This replaces the previous fastapi-admin runtime path, which depended on Tortoise-oriented internals that do not match this project. """ import secrets import uuid from dataclasses import dataclass from html import escape from typing import Any import aioredis from fastapi import APIRouter, Depends, Form, Request from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from starlette.responses import HTMLResponse, RedirectResponse from starlette.status import HTTP_303_SEE_OTHER, HTTP_401_UNAUTHORIZED from app.core.config import get_settings from app.database import get_db from app.models import Item, Session, Tryout from app.services.ai_generation import ( SUPPORTED_MODELS, generate_question, get_ai_stats, validate_ai_model, ) from app.services.irt_calibration import get_calibration_status settings = get_settings() router = APIRouter(prefix="/admin", tags=["admin-web"]) SESSION_COOKIE = "access_token" SESSION_PREFIX = "admin:session:" _admin_redis = None @dataclass class AdminPrincipal: username: str async def configure_admin_web() -> None: global _admin_redis if _admin_redis is not None: return if not settings.ADMIN_USERNAME or not settings.ADMIN_PASSWORD: raise RuntimeError("ENABLE_ADMIN=true requires ADMIN_USERNAME and ADMIN_PASSWORD to be set.") _admin_redis = aioredis.from_url( settings.REDIS_URL, encoding="utf-8", decode_responses=True, ) async def shutdown_admin_web() -> None: global _admin_redis if _admin_redis is None: return try: await _admin_redis.close() finally: _admin_redis = None async def _current_admin(request: Request) -> AdminPrincipal | None: if _admin_redis is None: return None token = request.cookies.get(SESSION_COOKIE) if not token: return None username = await _admin_redis.get(f"{SESSION_PREFIX}{token}") if not username: return None return AdminPrincipal(username=str(username)) def _login_redirect() -> RedirectResponse: return RedirectResponse(url="/admin/login", status_code=HTTP_303_SEE_OTHER) def _dashboard_redirect() -> RedirectResponse: return RedirectResponse(url="/admin/dashboard", status_code=HTTP_303_SEE_OTHER) def _render_auth_page( request: Request, title: str, subtitle: str, body: str, status_code: int = 200, ) -> HTMLResponse: remember_me_checked = "checked" if request.cookies.get("remember_me") == "on" else "" html = f"""
{escape(subtitle)}
{body.replace("__REMEMBER_ME_CHECKED__", remember_me_checked)}Direct environment-backed admin access.
""" return _render_auth_page( request, "Admin Login", "Use the configured admin credentials to access the dashboard.", body, ) @router.post("/login", include_in_schema=False) async def login_submit( request: Request, username: str = Form(...), password: str = Form(...), remember_me: str | None = Form(None), ): if not ( secrets.compare_digest(username, settings.ADMIN_USERNAME) and secrets.compare_digest(password, settings.ADMIN_PASSWORD) ): body = f"""Signed in as {escape(admin.username)}.
Password changes are disabled in the UI for this deployment.
Update ADMIN_PASSWORD in the server environment, then restart the app.
Session expiry is currently set to {settings.ADMIN_SESSION_EXPIRE_SECONDS} seconds.
""" return _render_auth_page( request, "Password Management", "Runtime password rotation is intentionally disabled.", body, ) @router.post("/password", include_in_schema=False) async def password_submit( request: Request, old_password: str = Form(...), new_password: str = Form(...), re_new_password: str = Form(...), ): _ = (old_password, new_password, re_new_password) admin = await _current_admin(request) if not admin: return _login_redirect() body = """Update ADMIN_PASSWORD in the server environment, then restart the app.
Signed in as {escape(admin.username)}.
Model: {escape(result.get("ai_model", ""))}
Stem:
{escape(result.get("stem", ""))}
Options:
{_table(["Key", "Text"], [[key, value] for key, value in options.items()])}Correct: {escape(result.get("correct", ""))}
Explanation:
{escape(result.get("explanation", ""))}
OpenRouter key configured: {"Yes" if key_configured else "No"}
Total AI-generated items: {stats.get("total_ai_items", 0)}
{error_html}This is a preview only. Save-to-database UI is not added yet.
{result_html} """ @router.get("/ai-playground", include_in_schema=False) async def ai_playground_view(request: Request, db: AsyncSession = Depends(get_db)): admin = await _current_admin(request) if not admin: return _login_redirect() stats = await get_ai_stats(db) body = _ai_form_body(bool(settings.OPENROUTER_API_KEY), stats) return _render_admin_page("AI Playground", "AI Playground", body) @router.post("/ai-playground", include_in_schema=False) async def ai_playground_submit( request: Request, db: AsyncSession = Depends(get_db), basis_item_id: int = Form(...), target_level: str = Form(...), ai_model: str = Form(...), ): admin = await _current_admin(request) if not admin: return _login_redirect() stats = await get_ai_stats(db) if not settings.OPENROUTER_API_KEY: body = _ai_form_body( False, stats, error="OPENROUTER_API_KEY is not configured in the environment.", basis_item_id=str(basis_item_id), target_level=target_level, ai_model=ai_model, ) return _render_admin_page("AI Playground", "AI Playground", body) if target_level not in {"mudah", "sulit"}: body = _ai_form_body( True, stats, error="Target level must be mudah or sulit.", basis_item_id=str(basis_item_id), target_level=target_level, ai_model=ai_model, ) return _render_admin_page("AI Playground", "AI Playground", body) if not validate_ai_model(ai_model): body = _ai_form_body( True, stats, error="Unsupported AI model.", basis_item_id=str(basis_item_id), target_level=target_level, ai_model=ai_model, ) return _render_admin_page("AI Playground", "AI Playground", body) result = await db.execute(select(Item).where(Item.id == basis_item_id)) basis_item = result.scalar_one_or_none() if not basis_item: body = _ai_form_body( True, stats, error=f"Basis item not found: {basis_item_id}", basis_item_id=str(basis_item_id), target_level=target_level, ai_model=ai_model, ) return _render_admin_page("AI Playground", "AI Playground", body) if basis_item.level != "sedang": body = _ai_form_body( True, stats, error=f"Basis item must be sedang level, got: {basis_item.level}", basis_item_id=str(basis_item_id), target_level=target_level, ai_model=ai_model, ) return _render_admin_page("AI Playground", "AI Playground", body) generated = await generate_question( basis_item=basis_item, target_level=target_level, ai_model=ai_model, ) if not generated: body = _ai_form_body( True, stats, error="AI generation failed. Check OPENROUTER_API_KEY, model availability, and server logs.", basis_item_id=str(basis_item_id), target_level=target_level, ai_model=ai_model, ) return _render_admin_page("AI Playground", "AI Playground", body) body = _ai_form_body( True, stats, result={ "stem": generated.stem, "options": generated.options, "correct": generated.correct, "explanation": generated.explanation or "", "ai_model": ai_model, }, basis_item_id=str(basis_item_id), target_level=target_level, ai_model=ai_model, ) return _render_admin_page("AI Playground", "AI Playground", body) @router.get("/tryout/list", include_in_schema=False) @router.get("/item/list", include_in_schema=False) @router.get("/user/list", include_in_schema=False) @router.get("/session/list", include_in_schema=False) @router.get("/tryoutstats/list", include_in_schema=False) async def legacy_admin_paths(request: Request): admin = await _current_admin(request) if not admin: return _login_redirect() return _dashboard_redirect()