Files
yellow-bank-soal/backend/app/admin_web.py
2026-06-20 01:43:39 +07:00

6457 lines
266 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 json
import re
import secrets
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from html import escape, unescape
from typing import Any
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from sqlalchemy import Integer, func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from starlette.responses import HTMLResponse, RedirectResponse
from starlette.status import (
HTTP_303_SEE_OTHER,
HTTP_401_UNAUTHORIZED,
HTTP_429_TOO_MANY_REQUESTS,
)
from app.admin_web_icons import EMOJI_TO_ICON, NAV_ICONS_SVG
from app.core.config import get_settings
from app.database import get_db
from app.models import (
AIGenerationRun,
Item,
Session,
Tryout,
TryoutImportSnapshot,
TryoutSnapshotQuestion,
TryoutStats,
UserAnswer,
Website,
)
from app.services.ai_generation import (
create_generation_run,
generate_questions_batch,
get_ai_stats,
save_ai_question,
)
from app.services.irt_calibration import get_calibration_status
from app.services.tryout_json_import import (
TryoutImportError,
import_tryout_json_snapshot,
preview_tryout_json_import,
)
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
@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)
# ============================================================
# ADMIN NAVIGATION - Human-friendly labels
# ============================================================
# Structure: (Label, URL, Child URL prefixes)
# Organized by workflow: Dashboard > Questions > Exams > Reports > Settings
ADMIN_NAV_ITEMS = (
# Dashboard
("Dashboard", "/admin/dashboard", ("/admin/dashboard",)),
# Questions - global question bank
(
"Questions",
"/admin/questions",
(
"/admin/questions",
"/admin/tryout/*/questions/*/workspace",
),
),
# Tryouts - hierarchy tree with drill-down
(
"Tryouts",
"/admin/tryouts",
(
"/admin/tryouts",
"/admin/tryout/*/attempts",
"/admin/tryout/*/questions",
"/admin/tryout/*/normalization",
"/admin/import-tryout",
),
),
# Import - tryout-level import
(
"Import",
"/admin/import-tryout",
(
"/admin/import-tryout",
"/admin/tryout-import",
"/admin/snapshot-questions",
),
),
# Reports
(
"Reports",
"/admin/reports",
(
"/admin/reports",
"/admin/item-statistics",
"/admin/calibration-status",
"/admin/session-overview",
),
),
# Settings
(
"Settings",
"/admin/settings",
(
"/admin/settings",
"/admin/websites",
"/admin/password",
),
),
# Logout (special - no active state)
("Logout", "/admin/logout", ("/admin/logout",)),
)
# URL mapping for backwards compatibility (old URLs -> new URLs)
LEGACY_URL_MAP = {
# Exams renamed to Tryouts
"/admin/exams": "/admin/tryouts",
"/admin/student-attempts": "/admin/tryouts",
# Legacy AI/question routes
"/admin/questions": "/admin/questions", # Keep as-is (global questions)
"/admin/basis-items": "/admin/tryouts",
"/admin/templates": "/admin/tryouts",
"/admin/question-quality": "/admin/tryouts",
"/admin/hierarchy": "/admin/tryouts",
"/admin/ai-generation": "/admin/tryouts",
# Reports
"/admin/calibration-status": "/admin/reports",
"/admin/item-statistics": "/admin/reports",
"/admin/session-overview": "/admin/reports",
}
# Navigation section icons (using SVG for consistent professional look)
NAV_ICONS = NAV_ICONS_SVG
def _breadcrumbs(
request: Request, items: list[tuple[str, str | None]] | None = None
) -> str:
"""Generate breadcrumb navigation HTML.
Args:
request: The FastAPI request object
items: List of (label, url) tuples. URL can be None for current page.
If None, returns empty string.
Returns:
HTML string for breadcrumbs, or empty string if no items.
"""
if not items:
return ""
crumbs = ['<nav class="breadcrumbs"><a href="/admin/dashboard">Dashboard</a>']
for i, (label, url) in enumerate(items):
if i > 0:
crumbs.append('<span class="sep"></span>')
if url:
crumbs.append(f'<a href="{escape(url)}">{escape(label)}</a>')
else:
crumbs.append(f'<span class="current">{escape(label)}</span>')
crumbs.append("</nav>")
return "".join(crumbs)
def _replace_emojis_with_icons(html: str) -> str:
"""Replace emoji characters with SVG icons in HTML content."""
for emoji, icon_svg in EMOJI_TO_ICON.items():
if emoji in html:
wrapped_svg = f'<span class="emoji-icon">{icon_svg}</span>'
html = html.replace(emoji, wrapped_svg)
return html
def _is_admin_nav_active(
current_path: str,
nav_path: str,
child_prefixes: tuple[str, ...],
) -> bool:
"""Check if the current path matches the nav item or its children.
Supports wildcard patterns like "/admin/tryout/*/questions" where * matches any single path segment.
"""
if current_path == nav_path:
return True
for prefix in child_prefixes:
if _path_matches_pattern(current_path, prefix):
return True
return False
def _path_matches_pattern(path: str, pattern: str) -> bool:
"""Match a path against a pattern that may contain wildcards (*).
Wildcards match a single path segment. For example:
- "/admin/tryout/*/questions" matches "/admin/tryout/123/questions"
- "/admin/tryout/*/questions/*/workspace" matches "/admin/tryout/123/questions/456/workspace"
"""
path_parts = path.strip("/").split("/")
pattern_parts = pattern.strip("/").split("/")
if len(path_parts) < len(pattern_parts):
return False
for i, part in enumerate(pattern_parts):
if part == "*":
continue # Wildcard matches any segment
if i >= len(path_parts) or path_parts[i] != part:
return False
return True
def _admin_nav_links(request: Request) -> str:
"""Render human-friendly navigation links with icons."""
current_path = request.url.path
# Check for legacy URLs and redirect if needed
for legacy_url, new_url in LEGACY_URL_MAP.items():
if current_path.startswith(legacy_url):
current_path = new_url
break
links = []
for label, path, child_prefixes in ADMIN_NAV_ITEMS:
# Special handling for Logout
if label == "Logout":
links.append(f'<a href="{path}" class="nav-logout">{escape(label)}</a>')
continue
active = _is_admin_nav_active(current_path, path, child_prefixes)
icon = NAV_ICONS.get(label, "")
label_html = f"{icon} {escape(label)}" if icon else escape(label)
class_attr = ' class="active"' if active else ""
aria = ' aria-current="page"' if active else ""
links.append(f'<a href="{path}"{class_attr}{aria}>{label_html}</a>')
return "\n ".join(links)
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"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{escape(title)}</title>
<style>
body {{ margin: 0; min-height: 100vh; display: grid; place-items: center; background: linear-gradient(135deg, #1e3a5f 0%, #0f172a 100%); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #0f172a; }}
.login-container {{ display: flex; flex-direction: column; align-items: center; gap: 24px; }}
.brand-header {{ text-align: center; margin-bottom: 8px; }}
.brand-logo {{ display: flex; align-items: center; justify-content: center; gap: 12px; margin-bottom: 8px; }}
.brand-logo svg {{ width: 48px; height: 48px; color: #3b82f6; }}
.brand-name {{ font-size: 28px; font-weight: 700; color: #fff; margin: 0; }}
.brand-tagline {{ color: #94a3b8; font-size: 14px; margin: 0; }}
.panel {{ width: min(420px, calc(100vw - 32px)); background: rgba(255,255,255,0.97); border-radius: 18px; box-shadow: 0 18px 60px rgba(0, 0, 0, 0.3); padding: 28px; }}
h2 {{ margin: 0 0 8px; font-size: 20px; color: #0f172a; }}
.subtitle {{ margin: 0 0 20px; color: #475569; font-size: 14px; }}
label {{ display: block; font-size: 14px; font-weight: 600; margin: 14px 0 8px; color: #334155; }}
input {{ width: 100%; box-sizing: border-box; border: 1px solid #cbd5e1; border-radius: 10px; padding: 12px 14px; font-size: 15px; transition: border-color 0.2s, box-shadow 0.2s; }}
input:focus {{ outline: none; border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); }}
.row {{ display: flex; align-items: center; gap: 10px; margin-top: 16px; color: #334155; font-size: 14px; }}
.row input {{ width: auto; }}
.remember-label {{ font-weight: 500; cursor: pointer; }}
.remember-hint {{ font-size: 12px; color: #94a3b8; margin-left: 24px; }}
button {{ width: 100%; margin-top: 18px; border: 0; border-radius: 10px; padding: 12px 14px; background: #3b82f6; color: #fff; font-size: 15px; font-weight: 600; cursor: pointer; transition: background 0.2s; }}
button:hover {{ background: #2563eb; }}
.error {{ margin: 0 0 16px; padding: 12px 14px; border-radius: 10px; background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }}
.muted {{ color: #64748b; font-size: 13px; margin-top: 14px; text-align: center; }}
a {{ color: #3b82f6; }}
.help-footer {{ color: #94a3b8; font-size: 12px; text-align: center; margin-top: 16px; }}
</style>
<script>
function showHelp() {{
alert('IRT Bank Soal Admin\\n\\nWelcome! To get started:\\n1. Enter your admin credentials\\n2. Click Sign In to access the dashboard\\n3. Import questions from Excel or JSON\\n4. Generate AI question variants\\n5. Review and calibrate questions');
}}
</script>
</head>
<body>
<div class="login-container">
<div class="brand-header">
<div class="brand-logo">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.26 10.147a60.438 60.438 0 0 0-.491 6.347A48.62 48.62 0 0 1 12 20.904a48.62 48.62 0 0 1 8.232-4.41 60.46 60.46 0 0 0-.491-6.347m-15.482 0a50.636 50.636 0 0 0-2.658-.813A59.906 59.906 0 0 1 12 3.493a59.903 59.903 0 0 1 10.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.717 50.717 0 0 1 12 13.489a50.702 50.702 0 0 1 7.74-3.342M6.75 15a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm0 0v-3.675A55.378 55.378 0 0 1 12 8.443m-7.007 11.55A5.981 5.981 0 0 0 6.75 15.75v-1.5" />
</svg>
<span class="brand-name">IRT Bank Soal</span>
</div>
<p class="brand-tagline">Adaptive Question Bank System</p>
</div>
<main class="panel">
<h2>{escape(title)}</h2>
<p class="subtitle">{escape(subtitle)}</p>
{body.replace("__REMEMBER_ME_CHECKED__", remember_me_checked)}
<p class="help-footer">Need help? <a href="#" onclick="showHelp(); return false;">View guide</a></p>
</main>
</div>
<button onclick="showHelp()" style="position: fixed; bottom: 24px; right: 24px; background: #3b82f6; color: #fff; border: none; border-radius: 50%; width: 48px; height: 48px; cursor: pointer; box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4); font-size: 20px;" title="Help">?</button>
</body>
</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, 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(
request: Request, title: str, page_title: str, body: str, breadcrumbs: str = ""
) -> HTMLResponse:
sidebar_links = _admin_nav_links(request)
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{escape(title)}</title>
<style>
/* ==========================================
BASE STYLES
========================================== */
* {{ box-sizing: border-box; }}
body {{ margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f7fb; color: #162033; line-height: 1.5; }}
a {{ color: #3b82f6; text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
/* ==========================================
LAYOUT
========================================== */
.layout {{ display: grid; grid-template-columns: 260px 1fr; min-height: 100vh; }}
/* ==========================================
SIDEBAR NAVIGATION - Human-friendly
========================================== */
.sidebar {{ background: linear-gradient(180deg, #0f172a 0%, #1e293b 100%); color: #e2e8f0; padding: 24px 16px; position: sticky; top: 0; height: 100vh; overflow-y: auto; }}
.sidebar h1 {{ font-size: 16px; margin: 0 0 24px; color: #fff; display: flex; align-items: center; gap: 8px; }}
.sidebar h1 .logo-icon {{ width: 24px; height: 24px; color: #3b82f6; }}
.sidebar .logo-icon svg {{ width: 24px; height: 24px; }}
.sidebar a {{ display: flex; align-items: center; gap: 10px; color: #94a3b8; text-decoration: none; padding: 12px 14px; border-radius: 10px; margin-bottom: 4px; font-size: 14px; transition: all 0.2s; }}
.sidebar a svg.nav-icon {{ width: 20px; height: 20px; flex-shrink: 0; }}
.sidebar a:hover {{ background: rgba(255,255,255,0.1); color: #fff; text-decoration: none; }}
.sidebar a.active {{ background: #3b82f6; color: #fff; font-weight: 600; box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); }}
.sidebar a.nav-logout {{ margin-top: 24px; border-top: 1px solid #334155; padding-top: 16px; color: #f87171; }}
.sidebar a.nav-logout:hover {{ background: rgba(248, 113, 113, 0.1); }}
/* ==========================================
MAIN CONTENT
========================================== */
.content {{ padding: 32px; max-width: 1400px; }}
.card {{ background: #fff; border-radius: 16px; padding: 28px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); }}
.card h2 {{ margin: 0 0 24px; font-size: 22px; color: #0f172a; }}
/* ==========================================
DASHBOARD STYLES - Human-friendly
========================================== */
.dashboard-hero {{ margin-bottom: 28px; }}
.dashboard-hero h1 {{ font-size: 28px; margin: 0 0 8px; color: #0f172a; }}
.dashboard-subtitle {{ color: #64748b; font-size: 16px; margin: 0; }}
/* How It Works Section */
.how-it-works {{ background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%); border-radius: 16px; padding: 24px; margin-bottom: 24px; border: 1px solid #bfdbfe; }}
.how-it-works-title {{ font-size: 18px; margin: 0 0 20px; color: #1e40af; font-weight: 600; }}
.flow-steps {{ display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; justify-content: center; }}
.flow-step {{ display: flex; flex-direction: column; align-items: center; text-align: center; min-width: 120px; }}
.step-num {{ display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: #3b82f6; color: #fff; font-size: 14px; font-weight: 700; margin-bottom: 8px; }}
.step-title {{ font-size: 14px; font-weight: 600; color: #1e40af; margin-bottom: 4px; }}
.step-desc {{ font-size: 12px; color: #3b82f6; }}
.step-arrow {{ font-size: 24px; color: #93c5fd; align-self: center; margin-top: 4px; }}
.flow-link {{ display: inline-block; margin-top: 16px; font-size: 14px; font-weight: 500; color: #2563eb; text-align: center; width: 100%; }}
.flow-link:hover {{ text-decoration: underline; }}
/* Getting Started / Empty State */
.getting-started {{ background: #fff; border-radius: 16px; padding: 32px; margin-bottom: 24px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); border: 1px solid #e2e8f0; }}
.getting-started h2 {{ font-size: 24px; margin: 0 0 8px; color: #0f172a; }}
.getting-started-intro {{ color: #64748b; margin: 0 0 24px; }}
.steps-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 20px; }}
.step-card {{ background: #f8fafc; border-radius: 12px; padding: 20px; border: 1px solid #e2e8f0; position: relative; }}
.step-card .num {{ position: absolute; top: 12px; right: 12px; display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 50%; background: #e2e8f0; color: #475569; font-size: 12px; font-weight: 700; }}
.step-card h3 {{ font-size: 16px; margin: 0 0 8px; color: #0f172a; }}
.step-card p {{ font-size: 14px; color: #64748b; margin: 0 0 16px; }}
.btn {{ display: inline-block; padding: 10px 16px; border-radius: 8px; font-size: 14px; font-weight: 600; text-decoration: none; transition: all 0.2s; }}
.btn-primary {{ background: #3b82f6; color: #fff; }}
.btn-primary:hover {{ background: #2563eb; text-decoration: none; }}
/* Breadcrumbs */
.breadcrumbs {{ display: flex; align-items: center; gap: 8px; margin-bottom: 24px; font-size: 14px; color: #64748b; }}
.breadcrumbs a {{ color: #3b82f6; text-decoration: none; }}
.breadcrumbs a:hover {{ text-decoration: underline; }}
.breadcrumbs .sep {{ color: #cbd5e1; }}
.breadcrumbs .current {{ color: #475569; font-weight: 500; }}
.section-title {{ font-size: 16px; color: #475569; margin: 32px 0 16px!important; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }}
.section-title:first-of-type {{ margin-top: 0; }}
.section-title svg {{ width: 32px; height: 32px; margin-bottom: -3px; }}
a.button-link svg {{ width: 16px; height: 16px; margin-bottom: -3px; }}
/* Metric Cards */
.metric-cards {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; }}
.metric-card {{ display: flex; align-items: center; gap: 16px; padding: 20px; border: 1px solid #e2e8f0; border-radius: 14px; background: #fff; transition: transform 0.2s, box-shadow 0.2s; }}
.metric-card:hover {{ transform: translateY(-2px); box-shadow: 0 8px 25px rgba(0,0,0,0.1); }}
.metric-card.metric-primary {{background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); border: none; color: #fff; }}
.metric-card.metric-primary .metric-label {{color: rgba(255,255,255,0.9); }}
.metric-card.metric-primary .metric-subtext {{color: rgba(255,255,255,0.7); }}
.metric-card.metric-primary .metric-value {{color: #ffffff; }}
.metric-icon {{ font-size: 32px; line-height: 1; }}
.metric-icon svg, .huge-icon, .page-icon {{ width: 32px; height: 32px; }}
.metric-icon svg.nav-icon {{ width: 32px; height: 32px; }}
.metric-value {{ font-size: 32px; font-weight: 700; color: #0f172a; line-height: 1; }}
.metric-label {{ font-size: 14px; color: #64748b; margin-top: 4px; }}
.metric-subtext {{ font-size: 12px; color: #94a3b8; margin-top: 4px; }}
/* Alerts */
.alert {{ padding: 16px 20px; border-radius: 12px; margin-bottom: 16px; display: flex; align-items: center; gap: 12px; }}
.alert svg, .alert svg.huge-icon, .alert svg.page-icon {{ width: 24px; height: 24px; flex-shrink: 0; margin-right: 4px; margin-bottom: -4px; }}
.alert-warning {{ background: #fef3c7; border: 1px solid #f59e0b; color: #92400e; }}
.alert-info {{ background: #dbeafe; border: 1px solid #3b82f6; color: #1e40af; }}
.alert-tip {{ background: #ecfdf5; border: 1px solid #10b981; color: #065f46; }}
.alert strong {{ font-weight: 600; }}
/* Quick Actions */
.quick-actions {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; }}
.quick-action {{ display: flex; align-items: center; gap: 16px; padding: 20px; border: 2px solid #e2e8f0; border-radius: 14px; background: #fff; text-decoration: none; color: inherit; transition: all 0.2s; }}
.quick-action:hover {{ border-color: #3b82f6; box-shadow: 0 8px 25px rgba(59, 130, 246, 0.15); text-decoration: none; transform: translateY(-2px); }}
.quick-action.primary {{ border-color: #3b82f6; background: #eff6ff; }}
.quick-action-icon {{ font-size: 28px; line-height: 1; }}
.quick-action-icon svg {{ width: 28px; height: 28px; }}
.quick-action-icon svg.nav-icon {{ width: 28px; height: 28px; }}
.quick-action-text {{ display: flex; flex-direction: column; }}
.quick-action-text strong {{ font-size: 15px; color: #0f172a; }}
* > svg {{ margin-bottom: -3px; }}
.quick-action-text small {{ font-size: 13px; color: #64748b; margin-top: 2px; }}
/* Activity Feed */
.activity-feed {{ list-style: none; padding: 0; margin: 0; }}
.activity-feed li {{ padding: 12px 16px; border-bottom: 1px solid #f1f5f9; display: flex; align-items: center; gap: 10px; }}
.activity-feed li:last-child {{ border-bottom: none; }}
.activity-feed li svg, .activity-feed li svg.nav-icon, .activity-feed li svg.huge-icon {{ width: 20px; height: 20px; flex-shrink: 0; }}
.activity-feed li:hover {{ background: #f8fafc; }}
/* ==========================================
LEGACY STYLES (kept for compatibility)
========================================== */
.grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-top: 20px; }}
.stat {{ padding: 18px; border: 1px solid #e2e8f0; border-radius: 12px; background: #f8fafc; }}
.stat strong {{ display: block; font-size: 26px; margin-top: 6px; }}
table {{ width: 100%; border-collapse: collapse; margin-top: 16px; background: #fff; border-radius: 12px; overflow: hidden; }}
th, td {{ padding: 12px 14px; border-bottom: 1px solid #e5e7eb; text-align: left; vertical-align: top; font-size: 14px; }}
th {{ background: #f8fafc; font-weight: 600; }}
input, select, textarea {{ width: 100%; box-sizing: border-box; border: 1px solid #cbd5e1; border-radius: 10px; padding: 12px 14px; font-size: 15px; }}
label {{ display: block; font-size: 14px; font-weight: 600; margin: 14px 0 8px; }}
button {{ border: 0; border-radius: 10px; padding: 12px 14px; background: #0f172a; color: #fff; font-size: 15px; font-weight: 600; cursor: pointer; transition: background 0.2s; }}
button:hover {{ background: #1e293b; }}
.actions {{ display: flex; gap: 12px; flex-wrap: wrap; margin-top: 18px; }}
.row {{ display: flex; align-items: center; gap: 10px; margin-top: 12px; color: #334155; font-size: 14px; }}
.row input {{ width: auto; }}
.error {{ margin: 0 0 16px; padding: 12px 14px; border-radius: 10px; background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }}
.success {{ margin: 0 0 16px; padding: 12px 14px; border-radius: 10px; background: #ecfdf5; color: #166534; border: 1px solid #86efac; }}
.muted {{ color: #64748b; font-size: 14px; }}
.tabs {{ display: flex; gap: 8px; flex-wrap: wrap; margin: 18px 0 18px; border-bottom: 1px solid #e2e8f0; }}
.tabs a {{ display: inline-flex; align-items: center; min-height: 38px; padding: 0 14px; color: #475569; text-decoration: none; border: 1px solid transparent; border-bottom: 0; border-radius: 8px 8px 0 0; font-weight: 700; font-size: 14px; }}
.tabs a.active {{ background: #fff; border-color: #e2e8f0; color: #0f172a; box-shadow: 0 -1px 0 #fff inset; }}
.compact-strip {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin: 14px 0; }}
.compact-stat {{ border: 1px solid #e2e8f0; border-radius: 8px; background: #f8fafc; padding: 12px 14px; }}
.compact-stat span {{ display: block; color: #64748b; font-size: 12px; font-weight: 700; text-transform: uppercase; }}
.compact-stat strong {{ display: block; margin-top: 4px; color: #0f172a; font-size: 20px; line-height: 1.1; }}
.field-grid {{ display: grid; grid-template-columns: repeat(2, minmax(180px, 1fr)); gap: 12px 16px; align-items: end; }}
.field-grid .wide {{ grid-column: 1 / -1; }}
.tab-panel {{ margin-top: 8px; }}
.toolbar {{ display: flex; align-items: end; gap: 12px; flex-wrap: wrap; margin: 12px 0 16px; }}
.toolbar label {{ min-width: 150px; margin-top: 0; margin-bottom: 0;}}
.toolbar input, .toolbar select {{ min-width: 150px; }}
.table-wrap {{ width: 100%; overflow-x: auto; }}
.table-wrap table {{ min-width: 860px; }}
.status-pill {{ display: inline-flex; align-items: center; min-height: 22px; padding: 0 8px; border-radius: 999px; background: #e2e8f0; color: #334155; font-size: 12px; font-weight: 700; }}
.status-approved, .status-active {{ background: #dcfce7; color: #166534; }}
.status-rejected, .status-archived {{ background: #fee2e2; color: #991b1b; }}
.status-draft {{ background: #e0f2fe; color: #075985; }}
.status-stale {{ background: #fef3c7; color: #92400e; }}
.button-link {{ display: inline-block; padding: 9px 12px; border-radius: 8px; background: #0f172a; color: #fff; text-decoration: none; font-size: 13px; font-weight: 700; }}
.secondary-link {{ display: inline-block; padding: 10px 12px; border-radius: 8px; background: #e2e8f0; color: #0f172a; text-decoration: none; font-size: 14px; font-weight: 700; }}
.question-block {{ margin: 16px 0; padding: 16px; border: 1px solid #e2e8f0; border-radius: 8px; background: #f8fafc; }}
.question-block h3 {{ margin-top: 0; }}
.option-key {{ width: 56px; font-weight: 800; color: #0f172a; }}
.correct-option td {{ background: #ecfdf5; color: #166534; font-weight: 700; }}
.flow-strip {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin: 16px 0 22px; }}
.flow-step {{ border: 1px solid #cbd5e1; border-radius: 8px; padding: 12px; background: #f8fafc; }}
.flow-step span {{ display: block; color: #64748b; font-size: 12px; font-weight: 800; text-transform: uppercase; }}
.flow-step strong {{ display: block; margin-top: 4px; }}
.hierarchy-website {{ border: 1px solid #e2e8f0; border-radius: 8px; padding: 18px; margin-top: 18px; }}
.hierarchy-group {{ border-left: 3px solid #cbd5e1; padding-left: 16px; margin-top: 16px; }}
.hierarchy-item {{ border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px; margin-top: 12px; background: #fff; }}
.badge {{ display: inline-flex; align-items: center; min-height: 22px; padding: 0 8px; border-radius: 999px; background: #e2e8f0; color: #334155; font-size: 12px; font-weight: 800; }}
.attention-list {{ margin: 10px 0 0; padding-left: 18px; color: #334155; }}
.attention-list li {{ margin: 6px 0; }}
/* ==========================================
RESPONSIVE DESIGN
========================================== */
@media (max-width: 1024px) {{
.layout {{ grid-template-columns: 1fr; }}
.sidebar {{ position: static; height: auto; padding: 16px; }}
.sidebar h1 {{ margin-bottom: 16px; }}
.sidebar a {{ padding: 10px 12px; font-size: 13px; }}
.content {{ padding: 20px; }}
}}
@media (max-width: 640px) {{
.metric-cards {{ grid-template-columns: repeat(2, 1fr); }}
.quick-actions {{ grid-template-columns: 1fr; }}
.dashboard-hero h1 {{ font-size: 22px; }}
.metric-value {{ font-size: 24px; }}
}}
/* ==========================================
ONBOARDING MODAL
========================================== */
.onboarding-btn {{ position: fixed; bottom: 24px; right: 24px; background: #3b82f6; color: #fff; border: none; border-radius: 50%; width: 56px; height: 56px; cursor: pointer; box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4); display: flex; align-items: center; justify-content: center; transition: all 0.2s; z-index: 100; }}
.onboarding-btn:hover {{ transform: scale(1.1); background: #2563eb; }}
.onboarding-btn svg {{ width: 24px; height: 24px; }}
.onboarding-overlay {{ display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(15, 23, 42, 0.6); z-index: 1000; }}
.onboarding-overlay.active {{ display: flex; align-items: center; justify-content: center; }}
.onboarding-modal {{ background: #fff; border-radius: 20px; width: min(600px, 90vw); max-height: 80vh; overflow: hidden; box-shadow: 0 25px 50px rgba(0, 0, 0, 0.25); }}
.onboarding-header {{ background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); color: #fff; padding: 24px; }}
.onboarding-header h2 {{ margin: 0 0 8px; font-size: 24px; }}
.onboarding-header p {{ margin: 0; opacity: 0.9; font-size: 14px; }}
.onboarding-close {{ position: absolute; top: 16px; right: 16px; background: rgba(255,255,255,0.2); border: none; border-radius: 50%; width: 32px; height: 32px; cursor: pointer; color: #fff; display: flex; align-items: center; justify-content: center; }}
.onboarding-close:hover {{ background: rgba(255,255,255,0.3); }}
.onboarding-close svg {{ width: 20px; height: 20px; }}
.onboarding-content {{ padding: 24px; max-height: 60vh; overflow-y: auto; }}
.onboarding-section {{ margin-bottom: 24px; }}
.onboarding-section:last-child {{ margin-bottom: 0; }}
.onboarding-section h3 {{ font-size: 16px; color: #0f172a; margin: 0 0 12px; display: flex; align-items: center; gap: 8px; }}
.onboarding-section h3 svg {{ width: 20px; height: 20px; color: #3b82f6; }}
.onboarding-cards {{ display: grid; gap: 12px; }}
.onboarding-card {{ border: 1px solid #e2e8f0; border-radius: 12px; padding: 16px; cursor: pointer; transition: all 0.2s; }}
.onboarding-card:hover {{ border-color: #3b82f6; background: #f8fafc; }}
.onboarding-card.selected {{ border-color: #3b82f6; background: #eff6ff; }}
.onboarding-card h4 {{ margin: 0 0 4px; font-size: 15px; color: #0f172a; }}
.onboarding-card p {{ margin: 0; font-size: 13px; color: #64748b; }}
.onboarding-steps {{ display: none; margin-top: 16px; padding: 16px; background: #f8fafc; border-radius: 12px; }}
.onboarding-card.expanded .onboarding-steps {{ display: block; }}
.onboarding-step {{ display: flex; gap: 12px; padding: 12px 0; border-bottom: 1px solid #e2e8f0; }}
.onboarding-step:last-child {{ border-bottom: none; padding-bottom: 0; }}
.step-number {{ width: 24px; height: 24px; background: #3b82f6; color: #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; flex-shrink: 0; }}
.step-content {{ flex: 1; }}
.step-content strong {{ display: block; font-size: 14px; color: #0f172a; margin-bottom: 2px; }}
.step-content a {{ font-size: 13px; color: #3b82f6; }}
.step-content span {{ font-size: 13px; color: #64748b; }}
.onboarding-footer {{ padding: 16px 24px; border-top: 1px solid #e2e8f0; display: flex; justify-content: space-between; align-items: center; }}
.onboarding-footer .hint {{ font-size: 12px; color: #94a3b8; }}
</style>
<script>
function openOnboarding() {{ document.getElementById('onboarding-overlay').classList.add('active'); }}
function closeOnboarding() {{ document.getElementById('onboarding-overlay').classList.remove('active'); }}
function toggleCard(el) {{
var cards = document.querySelectorAll('.onboarding-card');
for (var i = 0; i < cards.length; i++) {{
if (cards[i] !== el) {{
cards[i].classList.remove('expanded', 'selected');
}}
}}
el.classList.toggle('expanded');
el.classList.toggle('selected');
}}
document.addEventListener('keydown', function(e) {{ if(e.key === 'Escape') closeOnboarding(); }});
</script>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<h1><span class="logo-icon">{EMOJI_TO_ICON["🎯"]}</span>IRT Admin</h1>
{sidebar_links}
</aside>
<!-- Onboarding Modal -->
<div id="onboarding-overlay" class="onboarding-overlay" onclick="if(event.target === this) closeOnboarding()">
<div class="onboarding-modal">
<div class="onboarding-header" style="position: relative;">
<button class="onboarding-close" onclick="closeOnboarding()">{EMOJI_TO_ICON[""]}</button>
<h2>Welcome to IRT Admin!</h2>
<p>Choose a workflow below to get step-by-step guidance.</p>
</div>
<div class="onboarding-content">
<div class="onboarding-section">
<h3>{EMOJI_TO_ICON["🎯"]} Start Here</h3>
<div class="onboarding-cards">
<div class="onboarding-card" onclick="toggleCard(this)">
<h4>First Time Setup</h4>
<p>New to the system? Start here to understand the workflow.</p>
<div class="onboarding-steps">
<div class="onboarding-step">
<span class="step-number">1</span>
<div class="step-content">
<strong>Import your first questions</strong>
<a href="/admin/tryout-import">Go to Import Questions</a>
</div>
</div>
<div class="onboarding-step">
<span class="step-number">2</span>
<div class="step-content">
<strong>Review imported questions</strong>
<a href="/admin/questions">Go to Question Bank</a>
</div>
</div>
<div class="onboarding-step">
<span class="step-number">3</span>
<div class="step-content">
<strong>Generate AI variants for variety</strong>
<a href="/admin/basis-items">Go to AI Generator</a>
</div>
</div>
<div class="onboarding-step">
<span class="step-number">4</span>
<div class="step-content">
<strong>Create an exam when questions are ready</strong>
<a href="/admin/exams">Go to Exams</a>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="onboarding-section">
<h3>{EMOJI_TO_ICON["📥"]} Import Workflows</h3>
<div class="onboarding-cards">
<div class="onboarding-card" onclick="toggleCard(this)">
<h4>Import Questions from Excel</h4>
<p>Upload a .xlsx file with questions and answers.</p>
<div class="onboarding-steps">
<div class="onboarding-step">
<span class="step-number">1</span>
<div class="step-content">
<strong>Prepare your Excel file</strong>
<span>Format: Column A=Question, Column B-J=Options, Last column=Answer key</span>
</div>
</div>
<div class="onboarding-step">
<span class="step-number">2</span>
<div class="step-content">
<strong>Go to Import Questions</strong>
<a href="/admin/tryout-import">Open Import Page</a>
</div>
</div>
<div class="onboarding-step">
<span class="step-number">3</span>
<div class="step-content">
<strong>Preview & Submit</strong>
<span>Review the preview, then click Submit to import.</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="onboarding-section">
<h3>{EMOJI_TO_ICON["🤖"]} AI Generation</h3>
<div class="onboarding-cards">
<div class="onboarding-card" onclick="toggleCard(this)">
<h4>Generate AI Question Variants</h4>
<p>Create variations of existing questions using AI.</p>
<div class="onboarding-steps">
<div class="onboarding-step">
<span class="step-number">1</span>
<div class="step-content">
<strong>Select a template question</strong>
<a href="/admin/questions">Browse Question Bank</a>
</div>
</div>
<div class="onboarding-step">
<span class="step-number">2</span>
<div class="step-content">
<strong>Choose AI Generator</strong>
<a href="/admin/basis-items">Open AI Generator</a>
</div>
</div>
<div class="onboarding-step">
<span class="step-number">3</span>
<div class="step-content">
<strong>Review & Approve</strong>
<span>Check the Review tab for AI-generated variants.</span>
</div>
</div>
</div>
</div>
<div class="onboarding-card" onclick="toggleCard(this)">
<h4>Improve Question Quality</h4>
<p>Use AI to enhance unclear or weak questions.</p>
<div class="onboarding-steps">
<div class="onboarding-step">
<span class="step-number">1</span>
<div class="step-content">
<strong>Check Question Quality</strong>
<a href="/admin/question-quality">View Quality Dashboard</a>
</div>
</div>
<div class="onboarding-step">
<span class="step-number">2</span>
<div class="step-content">
<strong>Find low-quality questions</strong>
<span>Look for red indicators in the quality report.</span>
</div>
</div>
<div class="onboarding-step">
<span class="step-number">3</span>
<div class="step-content">
<strong>Generate improved variants</strong>
<a href="/admin/basis-items">Use AI Generator</a>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="onboarding-section">
<h3>{EMOJI_TO_ICON["📋"]} Exam Workflows</h3>
<div class="onboarding-cards">
<div class="onboarding-card" onclick="toggleCard(this)">
<h4>Create an Exam</h4>
<p>Set up a test session for students.</p>
<div class="onboarding-steps">
<div class="onboarding-step">
<span class="step-number">1</span>
<div class="step-content">
<strong>Ensure questions are calibrated</strong>
<a href="/admin/question-quality">Check Calibration</a>
</div>
</div>
<div class="onboarding-step">
<span class="step-number">2</span>
<div class="step-content">
<strong>Go to Exams</strong>
<a href="/admin/exams">Manage Exams</a>
</div>
</div>
<div class="onboarding-step">
<span class="step-number">3</span>
<div class="step-content">
<strong>Configure scoring mode</strong>
<span>Choose IRT scoring for adaptive testing.</span>
</div>
</div>
</div>
</div>
<div class="onboarding-card" onclick="toggleCard(this)">
<h4>Monitor Student Performance</h4>
<p>Track how students are doing on exams.</p>
<div class="onboarding-steps">
<div class="onboarding-step">
<span class="step-number">1</span>
<div class="step-content">
<strong>View exam reports</strong>
<a href="/admin/reports">Go to Reports</a>
</div>
</div>
<div class="onboarding-step">
<span class="step-number">2</span>
<div class="step-content">
<strong>Check calibration progress</strong>
<a href="/admin/question-quality">View Quality</a>
</div>
</div>
<div class="onboarding-step">
<span class="step-number">3</span>
<div class="step-content">
<strong>Understand IRT scoring</strong>
<span>Questions become more accurate as more students answer.</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="onboarding-footer">
<span class="hint">Click any card to see detailed steps</span>
</div>
</div>
</div>
<!-- Help Button -->
<button class="onboarding-btn" onclick="openOnboarding()" title="Help & Onboarding">
{EMOJI_TO_ICON[""]}
</button>
<main class="content">
{breadcrumbs}
<div class="card">
<h2>{escape(page_title)}</h2>
{_replace_emojis_with_icons(body)}
</div>
</main>
</div>
</body>
</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:
head = "".join(f"<th>{escape(str(header))}</th>" for header in headers)
body_rows = []
for row in rows:
cols = "".join(f"<td>{escape(str(value))}</td>" for value in row)
body_rows.append(f"<tr>{cols}</tr>")
body = "".join(body_rows) or f'<tr><td colspan="{len(headers)}">No data</td></tr>'
return f"<table><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table>"
def _truncate(text: str | None, max_length: int = 120) -> str:
if not text:
return ""
if len(text) <= max_length:
return text
return f"{text[: max_length - 3]}..."
def _html_to_text(value: str | None) -> str:
if not value:
return ""
text = re.sub(r"<[^>]+>", " ", value)
text = unescape(text)
text = re.sub(r"\s+", " ", text).strip()
return text
def _websites_form_body(
websites: list[Website],
error: str | None = None,
success: str | None = None,
site_name: str = "",
site_url: str = "",
) -> str:
error_html = f'<div class="error">{escape(error)}</div>' if error else ""
success_html = f'<div class="success">{escape(success)}</div>' if success else ""
body_rows = []
for website in websites:
actions = f"""
<div class="actions" style="margin-top:0">
<a href="/admin/websites/{website.id}/edit" style="display:inline-block;padding:8px 12px;border-radius:8px;background:#0f172a;color:#fff;text-decoration:none;">Edit</a>
<form method="post" action="/admin/websites/{website.id}/delete" onsubmit="return confirm('Delete website {escape(website.site_name)} and all related tryouts, items, sessions, and snapshots?');" style="margin:0">
<button type="submit" style="background:#991b1b;">Delete</button>
</form>
</div>
"""
body_rows.append(
"<tr>"
f"<td>{website.id}</td>"
f"<td>{escape(website.site_name)}</td>"
f"<td>{escape(website.site_url)}</td>"
f"<td>{actions}</td>"
"</tr>"
)
if body_rows:
websites_table = (
"<table><thead><tr><th>ID</th><th>Name</th><th>URL</th><th>Actions</th></tr></thead><tbody>"
+ "".join(body_rows)
+ "</tbody></table>"
)
else:
websites_table = _table(["ID", "Name", "URL", "Actions"], [])
return f"""
<p class="muted">Register websites here so imports and tryout references can be tied to a known source site.</p>
{success_html}
{error_html}
<form method="post" action="/admin/websites" autocomplete="off">
<label for="site_name">Website Name</label>
<input id="site_name" name="site_name" type="text" value="{escape(site_name)}" placeholder="Sejoli Demo Site">
<label for="site_url">Website URL</label>
<input id="site_url" name="site_url" type="url" value="{escape(site_url)}" placeholder="https://example.com">
<button type="submit">Add Website</button>
</form>
<h3 style="margin-top:24px">Registered Websites</h3>
<p class="muted">Use the website ID when importing read-only tryout snapshots.</p>
{websites_table}
"""
def _website_edit_form_body(
website: Website,
error: str | None = None,
success: str | None = None,
site_name: str | None = None,
site_url: str | None = None,
) -> str:
error_html = f'<div class="error">{escape(error)}</div>' if error else ""
success_html = f'<div class="success">{escape(success)}</div>' if success else ""
display_name = website.site_name if site_name is None else site_name
display_url = website.site_url if site_url is None else site_url
return f"""
<p class="muted">Website ID: <strong>{website.id}</strong></p>
{success_html}
{error_html}
<form method="post" action="/admin/websites/{website.id}/edit" autocomplete="off">
<label for="site_name">Website Name</label>
<input id="site_name" name="site_name" type="text" value="{escape(display_name)}">
<label for="site_url">Website URL</label>
<input id="site_url" name="site_url" type="url" value="{escape(display_url)}">
<div class="actions">
<button type="submit">Save Changes</button>
<a href="/admin/websites" style="display:inline-block;padding:12px 14px;border-radius:10px;background:#e2e8f0;color:#0f172a;text-decoration:none;font-size:15px;font-weight:600;">Back</a>
</div>
</form>
"""
def _tryout_import_form_body(
websites: list[Website],
recent_snapshots: list[TryoutImportSnapshot],
error: str | None = None,
success: str | None = None,
selected_website_id: int | None = None,
preview: dict[str, Any] | None = None,
preview_token: str | None = None,
upload_filename: str = "",
) -> str:
error_html = f'<div class="error">{escape(error)}</div>' if error else ""
success_html = f'<div class="success">{escape(success)}</div>' if success else ""
website_options = ['<option value="">Select website</option>']
for website in websites:
selected = "selected" if selected_website_id == website.id else ""
website_options.append(
f'<option value="{website.id}" {selected}>{escape(website.site_name)} (#{website.id})</option>'
)
website_map = {website.id: website.site_name for website in websites}
snapshot_rows = []
for snapshot in recent_snapshots:
snapshot_rows.append(
"<tr>"
f"<td>{snapshot.id}</td>"
f"<td>{escape(website_map.get(snapshot.website_id, 'Unknown'))} (#{snapshot.website_id})</td>"
f"<td>{escape(snapshot.source_tryout_id)}</td>"
f"<td>{escape(snapshot.title)}</td>"
f"<td>{snapshot.question_count}</td>"
f"<td>{escape(str(snapshot.created_at))}</td>"
f'<td><a href="/admin/snapshot-questions?snapshot_id={snapshot.id}" '
'style="display:inline-block;padding:8px 12px;border-radius:8px;background:#0f172a;color:#fff;text-decoration:none;">Browse</a></td>'
"</tr>"
)
snapshots_table = (
"<table><thead><tr><th>Snapshot ID</th><th>Website</th><th>Tryout ID</th><th>Title</th><th>Questions</th><th>Imported At</th><th>Actions</th></tr></thead><tbody>"
+ (
"".join(snapshot_rows)
if snapshot_rows
else '<tr><td colspan="7">No data</td></tr>'
)
+ "</tbody></table>"
)
preview_html = ""
if preview:
totals = preview.get("totals") or {}
tryout_rows = []
for tryout in preview.get("tryouts") or []:
diff = tryout.get("question_diff") or {}
warnings = "; ".join(tryout.get("warnings") or []) or "-"
tryout_rows.append(
[
tryout.get("source_tryout_id"),
tryout.get("title"),
diff.get("total_questions", 0),
diff.get("new_questions", 0),
diff.get("updated_questions", 0),
diff.get("unchanged_questions", 0),
diff.get("removed_questions", 0),
warnings,
]
)
import_form = ""
if preview_token and selected_website_id:
import_form = f"""
<form method="post" action="/admin/tryout-import" autocomplete="off">
<input type="hidden" name="website_id" value="{selected_website_id}">
<input type="hidden" name="preview_token" value="{escape(preview_token)}">
<button type="submit">Import Snapshot</button>
</form>
"""
preview_html = f"""
<h3 style="margin-top:24px">Preview Summary</h3>
<p class="muted">File: <strong>{
escape(upload_filename or "uploaded JSON")
}</strong></p>
<div class="grid">
<div class="stat">Tryouts<strong>{
preview.get("tryout_count", 0)
}</strong></div>
<div class="stat">New Questions<strong>{
totals.get("new_questions", 0)
}</strong></div>
<div class="stat">Updated Questions<strong>{
totals.get("updated_questions", 0)
}</strong></div>
<div class="stat">Removed Questions<strong>{
totals.get("removed_questions", 0)
}</strong></div>
</div>
{
_table(
[
"Tryout ID",
"Title",
"Total",
"New",
"Updated",
"Unchanged",
"Removed",
"Warnings",
],
tryout_rows,
)
}
<div class="actions">{import_form}</div>
"""
return f"""
<p class="muted">Import Sejoli tryout JSON as read-only snapshot reference data. This does not create live item-bank questions.</p>
<p class="muted">Use this when the source tryout changes upstream. Re-import updates matching source question IDs, inserts new ones, and marks missing ones inactive.</p>
{success_html}
{error_html}
<form method="post" action="/admin/tryout-import/preview" enctype="multipart/form-data" autocomplete="off">
<label for="website_id">Website</label>
<select id="website_id" name="website_id">{"".join(website_options)}</select>
<label for="file">Tryout Export JSON</label>
<input id="file" name="file" type="file" accept=".json,application/json">
<button type="submit">Preview Import</button>
</form>
{preview_html}
<h3 style="margin-top:24px">Recent Snapshots</h3>
<p class="muted">These are archived imports stored in PostgreSQL for traceability.</p>
{snapshots_table}
"""
def _snapshot_slot_map(snapshot: TryoutImportSnapshot) -> dict[str, int]:
slot_map: dict[str, int] = {}
questions = (snapshot.raw_payload or {}).get("questions") or []
for index, question in enumerate(questions, start=1):
source_question_id = str((question or {}).get("id") or "").strip()
if source_question_id:
slot_map[source_question_id] = index
return slot_map
def _snapshot_options_to_item_options(
raw_options: list[dict[str, Any]] | list[Any],
) -> dict[str, str]:
item_options: dict[str, str] = {}
for option in raw_options or []:
if not isinstance(option, dict):
continue
increment = str(option.get("increment") or "").strip().upper()
text = str(option.get("text") or option.get("label") or "").strip()
if increment and text:
item_options[increment] = text
return item_options
def _snapshot_questions_body(
snapshot: TryoutImportSnapshot,
questions: list[TryoutSnapshotQuestion],
promoted_items_by_slot: dict[int, Item],
error: str | None = None,
success: str | None = None,
) -> str:
error_html = f'<div class="error">{escape(error)}</div>' if error else ""
success_html = f'<div class="success">{escape(success)}</div>' if success else ""
slot_map = _snapshot_slot_map(snapshot)
rows = []
for question in questions:
slot = slot_map.get(question.source_question_id, 0)
promoted_item = promoted_items_by_slot.get(slot)
if promoted_item:
select_html = ""
action_html = (
f"Item #{promoted_item.id} already exists. "
f'<a href="/admin/questions/{promoted_item.id}/generate">Open in Variant Generator</a>'
)
else:
select_html = f'<input type="checkbox" name="snapshot_question_ids" value="{question.id}">'
action_html = "Ready to promote"
rows.append(
"<tr>"
f"<td>{select_html}</td>"
f"<td>{slot or '-'}</td>"
f"<td>{escape(question.source_question_id)}</td>"
f"<td>{escape(question.correct_answer)}</td>"
f"<td>{question.option_count}</td>"
f"<td>{'Yes' if question.is_active else 'No'}</td>"
f"<td>{escape(_truncate(question.question_title or question.question_html, 100))}</td>"
f"<td>{action_html}</td>"
"</tr>"
)
questions_table = (
'<form method="post" action="/admin/snapshot-questions/promote-bulk">'
f'<input type="hidden" name="snapshot_id" value="{snapshot.id}">'
'<div class="actions" style="margin:16px 0">'
'<button type="submit">Promote Selected as Basis Items</button>'
"</div>"
'<table><thead><tr><th><input type="checkbox" onclick="document.querySelectorAll(\'input[name=&quot;snapshot_question_ids&quot;]\').forEach(el => el.checked = this.checked)"></th><th>Slot</th><th>Source Question ID</th><th>Correct</th><th>Options</th><th>Active</th><th>Stem</th><th>Action</th></tr></thead><tbody>'
+ ("".join(rows) if rows else '<tr><td colspan="8">No data</td></tr>')
+ "</tbody></table>"
"</form>"
)
return f"""
<p class="muted">Snapshot ID: <strong>{snapshot.id}</strong> | Website: <strong>{snapshot.website_id}</strong> | Tryout: <strong>{escape(snapshot.source_tryout_id)}</strong></p>
<p class="muted">Promote selected snapshot questions into the live <code>items</code> table as original basis items (Medium difficulty) for AI generation.</p>
{success_html}
{error_html}
{questions_table}
<p style="margin-top:20px"><a href="/admin/tryout-import">Back to Tryout Import</a></p>
"""
async def _recent_generation_runs(
db: AsyncSession, limit: int = 20
) -> list[AIGenerationRun]:
result = await db.execute(
select(AIGenerationRun).order_by(AIGenerationRun.id.desc()).limit(limit)
)
return list(result.scalars().all())
async def _recent_generated_variants(
db: AsyncSession,
limit: int = 100,
basis_item_id: int | None = None,
status_filter: str | None = None,
level_filter: str | None = None,
run_id_filter: int | None = None,
) -> list[Item]:
stmt = select(Item).where(Item.generated_by == "ai")
if basis_item_id is not None:
stmt = stmt.where(Item.basis_item_id == basis_item_id)
if status_filter:
stmt = stmt.where(Item.variant_status == status_filter)
if level_filter:
stmt = stmt.where(Item.level == level_filter)
if run_id_filter is not None:
stmt = stmt.where(Item.generation_run_id == run_id_filter)
result = await db.execute(
stmt.order_by(Item.created_at.desc(), Item.id.desc()).limit(limit)
)
return list(result.scalars().all())
async def _load_hierarchy_context(db: AsyncSession) -> dict[str, list[Any]]:
website_result = await db.execute(select(Website).order_by(Website.id.asc()))
snapshot_result = await db.execute(
select(TryoutImportSnapshot).order_by(
TryoutImportSnapshot.website_id.asc(),
TryoutImportSnapshot.source_tryout_id.asc(),
TryoutImportSnapshot.id.desc(),
)
)
question_result = await db.execute(
select(TryoutSnapshotQuestion).order_by(
TryoutSnapshotQuestion.website_id.asc(),
TryoutSnapshotQuestion.source_tryout_id.asc(),
TryoutSnapshotQuestion.source_question_id.asc(),
)
)
basis_result = await db.execute(
select(Item)
.where(Item.generated_by != "ai", Item.level == "sedang")
.order_by(
Item.website_id.asc(), Item.tryout_id.asc(), Item.slot.asc(), Item.id.asc()
)
)
variant_result = await db.execute(
select(Item)
.where(Item.generated_by == "ai")
.order_by(Item.website_id.asc(), Item.basis_item_id.asc(), Item.id.desc())
)
run_result = await db.execute(
select(AIGenerationRun).order_by(
AIGenerationRun.basis_item_id.asc(),
AIGenerationRun.id.desc(),
)
)
return {
"websites": list(website_result.scalars().all()),
"snapshots": list(snapshot_result.scalars().all()),
"questions": list(question_result.scalars().all()),
"basis_items": list(basis_result.scalars().all()),
"variants": list(variant_result.scalars().all()),
"runs": list(run_result.scalars().all()),
}
def _append_grouped(grouped: dict[Any, list[Any]], key: Any, value: Any) -> None:
grouped.setdefault(key, []).append(value)
def _variant_status_counts_html(variants: list[Item]) -> str:
if not variants:
return '<span class="muted">No variants</span>'
counts: dict[str, int] = {}
for variant in variants:
counts[variant.variant_status] = counts.get(variant.variant_status, 0) + 1
return " ".join(
f"{_status_pill(status)} <strong>{count}</strong>"
for status, count in sorted(counts.items())
)
def _hierarchy_flow_strip() -> str:
steps = (
("1", "Website", "Owner/source site"),
("2", "Snapshot", "Imported tryout export"),
("3", "Source Question", "Read-only imported question"),
("4", "Basis Item", "Promoted original parent"),
("5", "Run", "AI generation request"),
("6", "Variant", "Generated child question"),
)
return (
'<div class="flow-strip">'
+ "".join(
f'<div class="flow-step"><span>{step}</span><strong>{escape(title)}</strong><p class="muted" style="margin:6px 0 0;">{escape(copy)}</p></div>'
for step, title, copy in steps
)
+ "</div>"
)
def _hierarchy_attention_html(
snapshots_without_basis: list[TryoutImportSnapshot],
basis_without_variants: list[Item],
variants_without_basis: list[Item],
basis_missing_source: list[Item],
) -> str:
rows = []
if snapshots_without_basis:
rows.append(
f'<li><span class="badge">Snapshot</span> <strong>{len(snapshots_without_basis)}</strong> snapshots have no promoted basis items yet. '
f'<span class="muted">(e.g., {escape(snapshots_without_basis[0].title)})</span></li>'
)
if basis_without_variants:
rows.append(
f'<li><span class="badge">Basis Item</span> <strong>{len(basis_without_variants)}</strong> promoted basis items have no generated variants yet. '
f'<a href="/admin/basis-items">Go to Basis Items to select an item for generation</a></li>'
)
if variants_without_basis:
rows.append(
f'<li><span class="badge">Variant</span> <strong>{len(variants_without_basis)}</strong> variants are orphaned (not linked to an existing basis item).</li>'
)
if basis_missing_source:
rows.append(
f'<li><span class="badge">Basis Item</span> <strong>{len(basis_missing_source)}</strong> basis items are missing a source snapshot question reference.</li>'
)
if not rows:
return """
<div class="success">No hierarchy gaps detected in the current data.</div>
"""
return f"""
<div class="question-block" style="border-left: 4px solid #eab308; background-color: #fefce8; padding: 16px; margin-bottom: 24px; border-radius: 4px;">
<h3 style="color: #ca8a04; margin-top: 0; font-size: 16px;">Needs Attention</h3>
<ul class="attention-list" style="margin-bottom: 0;">{"".join(rows)}</ul>
</div>
"""
def _basis_hierarchy_item_html(
basis_item: Item,
source_question: TryoutSnapshotQuestion | None,
variants: list[Item],
runs: list[AIGenerationRun],
) -> str:
latest_run = runs[0] if runs else None
source_label = "-"
if source_question is not None:
source_label = f"{escape(source_question.source_question_id)}"
run_html = "-"
if latest_run is not None:
run_html = f"Batch #{latest_run.id} ({escape(latest_run.target_level)})"
stem_preview = escape(_truncate(_html_to_text(basis_item.stem), 120))
variant_counts = (
_variant_status_counts_html(variants)
if variants
else '<span style="color: #ef4444; font-size: 12px; font-weight: bold;">0 variants</span>'
)
target_tab = "review" if variants else "generate"
return f"""
<tr style="border-bottom: 1px solid #eee;">
<td style="padding: 8px;"><strong>{basis_item.slot}</strong></td>
<td style="padding: 8px;" title="{escape(_html_to_text(basis_item.stem))}">{stem_preview}</td>
<td style="padding: 8px;">{variant_counts}</td>
<td style="padding: 8px;"><a class="secondary-link" href="/admin/questions/{basis_item.id}/generate?tab={target_tab}">Workspace</a></td>
</tr>
"""
def _hierarchy_view_body(context: dict[str, list[Any]]) -> str:
websites: list[Website] = context["websites"]
snapshots: list[TryoutImportSnapshot] = context["snapshots"]
questions: list[TryoutSnapshotQuestion] = context["questions"]
basis_items: list[Item] = context["basis_items"]
variants: list[Item] = context["variants"]
runs: list[AIGenerationRun] = context["runs"]
snapshots_by_website: dict[int, list[TryoutImportSnapshot]] = {}
questions_by_website: dict[int, list[TryoutSnapshotQuestion]] = {}
questions_by_snapshot: dict[int, list[TryoutSnapshotQuestion]] = {}
questions_by_id = {question.id: question for question in questions}
basis_by_website: dict[int, list[Item]] = {}
basis_by_source_question: dict[int, list[Item]] = {}
variants_by_website: dict[int, list[Item]] = {}
variants_by_basis: dict[int, list[Item]] = {}
runs_by_basis: dict[int, list[AIGenerationRun]] = {}
basis_by_id = {item.id: item for item in basis_items}
for snapshot in snapshots:
_append_grouped(snapshots_by_website, snapshot.website_id, snapshot)
for question in questions:
_append_grouped(questions_by_website, question.website_id, question)
if question.latest_snapshot_id is not None:
_append_grouped(
questions_by_snapshot, question.latest_snapshot_id, question
)
for item in basis_items:
_append_grouped(basis_by_website, item.website_id, item)
if item.source_snapshot_question_id is not None:
_append_grouped(
basis_by_source_question, item.source_snapshot_question_id, item
)
for variant in variants:
_append_grouped(variants_by_website, variant.website_id, variant)
if variant.basis_item_id is not None:
_append_grouped(variants_by_basis, variant.basis_item_id, variant)
for run in runs:
_append_grouped(runs_by_basis, run.basis_item_id, run)
snapshots_without_basis = []
for snapshot in snapshots:
snapshot_question_ids = {
question.id for question in questions_by_snapshot.get(snapshot.id, [])
}
linked_basis = [
item
for question_id in snapshot_question_ids
for item in basis_by_source_question.get(question_id, [])
]
if not linked_basis:
snapshots_without_basis.append(snapshot)
basis_without_variants = [
item for item in basis_items if not variants_by_basis.get(item.id)
]
variants_without_basis = [
item
for item in variants
if item.basis_item_id is None or item.basis_item_id not in basis_by_id
]
basis_missing_source = [
item for item in basis_items if item.source_snapshot_question_id is None
]
website_sections = []
for website in websites:
website_snapshots = snapshots_by_website.get(website.id, [])
website_questions = questions_by_website.get(website.id, [])
website_basis = basis_by_website.get(website.id, [])
website_variants = variants_by_website.get(website.id, [])
website_runs = [
run for item in website_basis for run in runs_by_basis.get(item.id, [])
]
snapshot_groups = []
for snapshot in website_snapshots:
snapshot_questions = questions_by_snapshot.get(snapshot.id, [])
snapshot_question_ids = {question.id for question in snapshot_questions}
snapshot_basis = sorted(
[
item
for question_id in snapshot_question_ids
for item in basis_by_source_question.get(question_id, [])
],
key=lambda item: (item.slot, item.id),
)
if snapshot_basis:
basis_html = (
"""
<table style="width:100%; border-collapse: collapse; margin-top: 10px; font-size: 13px;">
<thead>
<tr style="text-align: left; border-bottom: 2px solid #ddd;">
<th style="padding: 8px; width: 60px;">Slot</th>
<th style="padding: 8px;">Stem Preview</th>
<th style="padding: 8px; width: 100px;">Variants</th>
<th style="padding: 8px; width: 120px;">Action</th>
</tr>
</thead>
<tbody>
"""
+ "".join(
_basis_hierarchy_item_html(
item,
questions_by_id.get(item.source_snapshot_question_id),
variants_by_basis.get(item.id, []),
runs_by_basis.get(item.id, []),
)
for item in snapshot_basis
)
+ """
</tbody>
</table>
"""
)
else:
basis_html = '<div class="hierarchy-item"><p class="muted" style="margin:0;">No promoted basis items for this snapshot yet.</p></div>'
snapshot_groups.append(
f"""
<div class="hierarchy-group">
<p style="margin:0 0 8px;"><span class="badge">Snapshot</span> <strong>{escape(snapshot.title)}</strong></p>
<p class="muted" style="margin:0 0 8px;">Tryout: <strong>{escape(snapshot.source_tryout_id)}</strong> | Snapshot #{snapshot.id} | Imported: {escape(str(snapshot.created_at))}</p>
<p class="muted" style="margin:0 0 8px;">Questions in export: <strong>{snapshot.question_count}</strong> | Current source rows: <strong>{len(snapshot_questions)}</strong> | Promoted basis items: <strong>{len(snapshot_basis)}</strong></p>
<div class="actions" style="margin-top:10px;">
<a class="secondary-link" href="/admin/snapshot-questions?snapshot_id={snapshot.id}">Snapshot questions</a>
</div>
{basis_html}
</div>
"""
)
website_sections.append(
f"""
<section class="hierarchy-website">
<p style="margin:0 0 8px;"><span class="badge">Website</span> <strong>{escape(website.site_name)}</strong> | #{website.id}</p>
<p class="muted" style="margin:0 0 12px;">{escape(website.site_url)}</p>
<div class="compact-strip">
<div class="compact-stat"><span>Snapshots</span><strong>{len(website_snapshots)}</strong></div>
<div class="compact-stat"><span>Source Questions</span><strong>{len(website_questions)}</strong></div>
<div class="compact-stat"><span>Basis Items</span><strong>{len(website_basis)}</strong></div>
<div class="compact-stat"><span>AI Runs</span><strong>{len(website_runs)}</strong></div>
<div class="compact-stat"><span>Variants</span><strong>{len(website_variants)}</strong></div>
</div>
{"".join(snapshot_groups) if snapshot_groups else '<div class="hierarchy-item"><p class="muted" style="margin:0;">No tryout imports have been recorded for this website yet.</p></div>'}
</section>
"""
)
if not website_sections:
website_sections.append(
'<div class="hierarchy-item"><p class="muted" style="margin:0;">No websites have been registered yet.</p></div>'
)
return f"""
<p class="muted">This read-only view shows how source tryout data becomes reviewable AI-generated question variants.</p>
{_hierarchy_flow_strip()}
{_hierarchy_attention_html(snapshots_without_basis, basis_without_variants, variants_without_basis, basis_missing_source)}
{"".join(website_sections)}
"""
async def _usage_metrics_for_items(
db: AsyncSession,
item_ids: list[int],
) -> dict[int, dict[str, float]]:
if not item_ids:
return {}
result = await db.execute(
select(
UserAnswer.item_id,
func.count(UserAnswer.id).label("impressions"),
func.count(func.distinct(UserAnswer.wp_user_id)).label("unique_users"),
)
.where(UserAnswer.item_id.in_(item_ids))
.group_by(UserAnswer.item_id)
)
metrics: dict[int, dict[str, float]] = {}
for item_id, impressions, unique_users in result.all():
impressions_i = int(impressions or 0)
unique_users_i = int(unique_users or 0)
frequency = (impressions_i / unique_users_i) if unique_users_i else 0.0
metrics[int(item_id)] = {
"impressions": float(impressions_i),
"unique_users": float(unique_users_i),
"frequency": float(frequency),
}
return metrics
async def _family_usage_stats(
db: AsyncSession,
basis_item: Item,
variants: list[Item],
) -> tuple[dict[int, dict[str, float]], dict[str, float]]:
family_item_ids = [basis_item.id] + [item.id for item in variants]
usage_metrics = await _usage_metrics_for_items(db, family_item_ids)
family_impressions = int(
sum(metric["impressions"] for metric in usage_metrics.values())
)
family_unique_users = int(
await db.scalar(
select(func.count(func.distinct(UserAnswer.wp_user_id))).where(
UserAnswer.item_id.in_(family_item_ids)
)
)
or 0
)
family_frequency = (
(family_impressions / family_unique_users) if family_unique_users else 0.0
)
return usage_metrics, {
"impressions": float(family_impressions),
"unique_users": float(family_unique_users),
"frequency": float(family_frequency),
}
def _basis_items_list_body(items: list[Item]) -> str:
rows = []
for item in items:
rows.append(
"<tr>"
f"<td>{item.id}</td>"
f"<td>{escape(item.tryout_id)}</td>"
f"<td>{item.slot}</td>"
f"<td>{item.website_id}</td>"
f"<td>{escape(_truncate(item.stem, 120))}</td>"
f"<td>{item.source_snapshot_question_id or '-'}</td>"
f'<td><a href="/admin/basis-items/{item.id}" style="display:inline-block;padding:8px 12px;border-radius:8px;background:#0f172a;color:#fff;text-decoration:none;">Open Workspace</a></td>'
"</tr>"
)
table = (
"<table><thead><tr><th>Item ID</th><th>Tryout</th><th>Slot</th><th>Website</th><th>Stem</th><th>Source Snapshot QID</th><th>Actions</th></tr></thead><tbody>"
+ (
"".join(rows)
if rows
else '<tr><td colspan="7">No basis items found.</td></tr>'
)
+ "</tbody></table>"
)
return f"""
<p class="muted">Basis items are original parent questions (Medium difficulty, non-AI). Open a workspace to generate and review AI child variants.</p>
{table}
"""
def _basis_item_workspace_body(
basis_item: Item,
runs: list[AIGenerationRun],
variants: list[Item],
usage_by_item: dict[int, dict[str, float]],
family_stats: dict[str, float],
filters: dict[str, str],
error: str | None = None,
success: str | None = None,
target_level: str = "mudah",
ai_model: str = settings.OPENROUTER_MODEL_LLAMA,
generation_count: str = "1",
operator_notes: str = "",
include_note_for_admin: bool = True,
include_note_in_prompt: bool = False,
) -> str:
error_html = f'<div class="error">{escape(error)}</div>' if error else ""
success_html = f'<div class="success">{escape(success)}</div>' if success else ""
status_filter = filters.get("status", "")
level_filter = filters.get("level", "")
min_frequency_filter = filters.get("min_frequency", "")
run_id_filter = filters.get("run_id", "")
run_rows = [
[
run.id,
run.target_level,
run.requested_count,
run.model,
run.created_by,
str(run.created_at),
]
for run in runs
]
runs_table = _table(
["Run ID", "Target", "Requested", "Model", "Created By", "Created At"],
run_rows,
)
variant_rows = []
for item in variants:
usage = usage_by_item.get(
item.id, {"impressions": 0.0, "unique_users": 0.0, "frequency": 0.0}
)
options = item.options if isinstance(item.options, dict) else {}
options_rows = (
"".join(
f'<tr><td style="padding:6px 8px;border-bottom:1px solid #e5e7eb;"><strong>{escape(str(key))}</strong></td>'
f'<td style="padding:6px 8px;border-bottom:1px solid #e5e7eb;">{escape(str(value))}</td></tr>'
for key, value in options.items()
)
or '<tr><td colspan="2" style="padding:6px 8px;">No options</td></tr>'
)
review_html = (
'<details style="margin-top:8px;">'
'<summary style="cursor:pointer;color:#0f172a;font-weight:600;">Review full content</summary>'
f'<div style="margin-top:8px;padding:10px;border:1px solid #e2e8f0;border-radius:8px;background:#f8fafc;">'
f'<p style="margin:0 0 8px;"><strong>Full Stem</strong><br>{escape(_html_to_text(item.stem))}</p>'
'<table style="margin:0 0 8px;width:100%;border-collapse:collapse;">'
'<thead><tr><th style="text-align:left;padding:6px 8px;background:#eef2ff;">Option</th><th style="text-align:left;padding:6px 8px;background:#eef2ff;">Text</th></tr></thead>'
f"<tbody>{options_rows}</tbody>"
"</table>"
f'<p style="margin:0 0 6px;"><strong>Correct Answer:</strong> {escape(item.correct_answer or "-")}</p>'
f'<p style="margin:0;"><strong>Explanation:</strong> {escape(_html_to_text(item.explanation) or "-")}</p>'
"</div>"
"</details>"
)
variant_rows.append(
"<tr>"
f'<td><input type="checkbox" name="item_ids" value="{item.id}"></td>'
f"<td>{item.id}</td>"
f"<td>{item.generation_run_id or '-'}</td>"
f"<td>{escape(item.level)}</td>"
f"<td>{escape(item.variant_status)}</td>"
f"<td>{escape(item.ai_model or '-')}</td>"
f"<td>{int(usage['impressions'])}</td>"
f"<td>{int(usage['unique_users'])}</td>"
f"<td>{usage['frequency']:.2f}</td>"
f"<td>{escape(_truncate(_html_to_text(item.stem), 130))}{review_html}</td>"
f"<td>{escape(str(item.created_at))}</td>"
"</tr>"
)
variants_table = (
f'<form method="post" action="/admin/basis-items/{basis_item.id}/review-bulk">'
'<div class="actions" style="margin:16px 0">'
'<select name="action" style="max-width:260px">'
'<option value="approved">Approve selected</option>'
'<option value="rejected">Reject selected</option>'
'<option value="archived">Archive selected</option>'
'<option value="stale">Mark stale</option>'
'<option value="active">Activate selected</option>'
"</select>"
'<button type="submit">Apply</button>'
"</div>"
'<table><thead><tr><th><input type="checkbox" onclick="document.querySelectorAll(\'input[name=&quot;item_ids&quot;]\').forEach(el => el.checked = this.checked)"></th><th>Item ID</th><th>Run ID</th><th>Level</th><th>Status</th><th>Model</th><th>Impressions</th><th>Unique Users</th><th>Frequency</th><th>Stem</th><th>Created At</th></tr></thead><tbody>'
+ (
"".join(variant_rows)
if variant_rows
else '<tr><td colspan="11">No generated variants yet for this parent.</td></tr>'
)
+ "</tbody></table></form>"
)
return f"""
{success_html}
{error_html}
<section style="border:1px solid #e2e8f0;border-radius:12px;padding:16px;background:#f8fafc;">
<h3 style="margin:0 0 10px;">Parent Summary</h3>
<p class="muted" style="margin:0 0 8px;">
Parent Item: <strong>#{basis_item.id}</strong> |
Tryout: <strong>{escape(basis_item.tryout_id)}</strong> |
Slot: <strong>{basis_item.slot}</strong> |
Website: <strong>{basis_item.website_id}</strong> |
Source Snapshot QID: <strong>{basis_item.source_snapshot_question_id or "-"}</strong>
</p>
<p class="muted" style="margin:0 0 8px;">
Family Usage: impressions=<strong>{int(family_stats.get("impressions", 0.0))}</strong>,
unique users=<strong>{int(family_stats.get("unique_users", 0.0))}</strong>,
frequency=<strong>{family_stats.get("frequency", 0.0):.2f}</strong>
</p>
<p class="muted" style="margin:0;"><strong>Stem:</strong> {escape(_truncate(_html_to_text(basis_item.stem), 260))}</p>
</section>
<section style="margin-top:16px;border:1px solid #e2e8f0;border-radius:12px;padding:16px;background:#fff;">
<h3 style="margin:0 0 8px;">Generate Variants</h3>
<p class="muted" style="margin:0 0 12px;">Create new AI child variants for this parent.</p>
<form method="post" action="/admin/basis-items/{basis_item.id}/generate" autocomplete="off">
<label for="target_level">Target Level</label>
<select id="target_level" name="target_level">
<option value="mudah" {"selected" if target_level == "mudah" else ""}>Easier</option>
<option value="sulit" {"selected" if target_level == "sulit" else ""}>Harder</option>
</select>
<label for="ai_model">Model</label>
<input id="ai_model" name="ai_model" type="text" value="{escape(settings.OPENROUTER_MODEL_LLAMA)}" readonly>
<label for="generation_count">Generate Count</label>
<input id="generation_count" name="generation_count" type="number" min="1" max="50" value="{escape(generation_count)}">
<p class="muted">Recommended: 1-3 per run. Larger runs increase overlap and review burden.</p>
<label for="operator_notes">Operator Notes (optional)</label>
<textarea id="operator_notes" name="operator_notes" rows="3">{escape(operator_notes)}</textarea>
<label class="row"><input type="checkbox" name="include_note_for_admin" {"checked" if include_note_for_admin else ""}> Save note for admin team (visible in run history)</label>
<label class="row"><input type="checkbox" name="include_note_in_prompt" {"checked" if include_note_in_prompt else ""}> Include note in AI prompt payload</label>
<p class="muted" style="margin-top:6px;">Example note: <code>Use clinical language, avoid negatives, keep stem under 40 words.</code></p>
<button type="submit">Generate Variants</button>
</form>
</section>
<section style="margin-top:16px;border:1px solid #e2e8f0;border-radius:12px;padding:16px;background:#fff;">
<h3 style="margin:0 0 8px;">Filter Variants</h3>
<p class="muted" style="margin:0 0 12px;">Filter child variants shown in the review table below.</p>
<form method="get" action="/admin/basis-items/{basis_item.id}" autocomplete="off">
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px;align-items:end;">
<div>
<label for="status" style="margin:0 0 6px;">Status</label>
<select id="status" name="status">
<option value="" {"selected" if status_filter == "" else ""}>All</option>
<option value="draft" {"selected" if status_filter == "draft" else ""}>draft</option>
<option value="approved" {"selected" if status_filter == "approved" else ""}>approved</option>
<option value="active" {"selected" if status_filter == "active" else ""}>active</option>
<option value="rejected" {"selected" if status_filter == "rejected" else ""}>rejected</option>
<option value="archived" {"selected" if status_filter == "archived" else ""}>archived</option>
<option value="stale" {"selected" if status_filter == "stale" else ""}>stale</option>
</select>
</div>
<div>
<label for="level" style="margin:0 0 6px;">Level</label>
<select id="level" name="level">
<option value="" {"selected" if level_filter == "" else ""}>All</option>
<option value="mudah" {"selected" if level_filter == "mudah" else ""}>Easier</option>
<option value="sulit" {"selected" if level_filter == "sulit" else ""}>Harder</option>
</select>
</div>
<div>
<label for="run_id" style="margin:0 0 6px;">Run ID</label>
<input id="run_id" name="run_id" type="number" min="1" value="{escape(run_id_filter)}">
</div>
<div>
<label for="min_frequency" style="margin:0 0 6px;">Min Frequency</label>
<input id="min_frequency" name="min_frequency" type="number" min="0" step="0.1" value="{escape(min_frequency_filter)}">
</div>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
<button type="submit">Apply</button>
<a href="/admin/basis-items/{basis_item.id}" style="display:inline-block;padding:12px 14px;border-radius:10px;background:#e2e8f0;color:#0f172a;text-decoration:none;font-size:15px;font-weight:600;">Reset</a>
</div>
</div>
</form>
</section>
<section style="margin-top:16px;border:1px solid #e2e8f0;border-radius:12px;padding:16px;background:#fff;">
<h3 style="margin:0 0 8px;">Child Variants for This Parent</h3>
<p class="muted">Filtered variants shown: <strong>{len(variants)}</strong></p>
{variants_table}
</section>
<section style="margin-top:16px;border:1px solid #e2e8f0;border-radius:12px;padding:16px;background:#fff;">
<h3 style="margin:0 0 12px;">Generation Runs for This Parent</h3>
<p class="muted" style="margin:0 0 10px;">Run history is reference/audit data and is intentionally separated from variant review workflow.</p>
{runs_table}
</section>
<p style="margin-top:20px"><a href="/admin/basis-items">Back to Basis Items</a></p>
"""
async def _find_or_create_demo_basis_item(db: AsyncSession) -> Item:
result = await db.execute(
select(Item)
.where(
Item.level == "sedang",
Item.generated_by == "manual",
Item.tryout_id == "demo-tryout",
)
.order_by(Item.id.asc())
.limit(1)
)
existing_item = result.scalar_one_or_none()
if existing_item:
return existing_item
website_result = await db.execute(
select(Website).where(Website.site_url == "https://demo.local").limit(1)
)
website = website_result.scalar_one_or_none()
if website is None:
website = Website(site_url="https://demo.local", site_name="Demo Website")
db.add(website)
await db.flush()
tryout_result = await db.execute(
select(Tryout)
.where(Tryout.website_id == website.id, Tryout.tryout_id == "demo-tryout")
.limit(1)
)
tryout = tryout_result.scalar_one_or_none()
if tryout is None:
tryout = Tryout(
website_id=website.id,
tryout_id="demo-tryout",
name="Demo Variant Generator Tryout",
description="Seed data for the AI playground.",
scoring_mode="ctt",
selection_mode="fixed",
normalization_mode="static",
ai_generation_enabled=True,
)
db.add(tryout)
await db.flush()
item = Item(
tryout_id=tryout.tryout_id,
website_id=website.id,
slot=1,
level="sedang",
stem="Sebuah toko memberi diskon 20% untuk sebuah tas. Jika harga setelah diskon adalah Rp240.000, berapakah harga tas sebelum diskon?",
options={
"A": "Rp260.000",
"B": "Rp300.000",
"C": "Rp320.000",
"D": "Rp360.000",
},
correct_answer="B",
explanation="Harga setelah diskon 20% berarti 80% dari harga awal. Jadi harga awal = 240.000 / 0,8 = 300.000.",
generated_by="manual",
calibrated=False,
calibration_sample_size=0,
)
db.add(item)
await db.flush()
await db.commit()
await db.refresh(item)
return item
async def _load_websites(db: AsyncSession) -> list[Website]:
result = await db.execute(select(Website).order_by(Website.id.asc()))
return list(result.scalars().all())
async def _recent_snapshots(
db: AsyncSession, limit: int = 20
) -> list[TryoutImportSnapshot]:
result = await db.execute(
select(TryoutImportSnapshot)
.order_by(TryoutImportSnapshot.id.desc())
.limit(limit)
)
return list(result.scalars().all())
async def _ensure_operational_tryout(
snapshot: TryoutImportSnapshot, db: AsyncSession
) -> Tryout:
result = await db.execute(
select(Tryout).where(
Tryout.website_id == snapshot.website_id,
Tryout.tryout_id == snapshot.source_tryout_id,
)
)
tryout = result.scalar_one_or_none()
if tryout:
return tryout
tryout = Tryout(
website_id=snapshot.website_id,
tryout_id=snapshot.source_tryout_id,
name=snapshot.title,
description=f"Operational tryout basis created from imported snapshot #{snapshot.id}.",
scoring_mode="ctt",
selection_mode="fixed",
normalization_mode="static",
ai_generation_enabled=True,
)
db.add(tryout)
await db.flush()
return tryout
async def _load_snapshot_question_context(
snapshot: TryoutImportSnapshot,
db: AsyncSession,
) -> tuple[list[TryoutSnapshotQuestion], dict[int, Item], dict[str, int]]:
question_result = await db.execute(
select(TryoutSnapshotQuestion)
.where(
TryoutSnapshotQuestion.website_id == snapshot.website_id,
TryoutSnapshotQuestion.source_tryout_id == snapshot.source_tryout_id,
)
.order_by(TryoutSnapshotQuestion.source_question_id.asc())
)
questions = list(question_result.scalars().all())
item_result = await db.execute(
select(Item).where(
Item.website_id == snapshot.website_id,
Item.tryout_id == snapshot.source_tryout_id,
Item.level == "sedang",
)
)
promoted_items_by_slot = {item.slot: item for item in item_result.scalars().all()}
slot_map = _snapshot_slot_map(snapshot)
questions.sort(
key=lambda row: (
slot_map.get(row.source_question_id, 10**9),
row.source_question_id,
)
)
return questions, promoted_items_by_slot, slot_map
async def _promote_snapshot_question_to_item(
snapshot: TryoutImportSnapshot,
question: TryoutSnapshotQuestion,
db: AsyncSession,
) -> tuple[Item | None, str]:
if (
question.website_id != snapshot.website_id
or question.source_tryout_id != snapshot.source_tryout_id
):
return None, "mismatch"
slot_map = _snapshot_slot_map(snapshot)
slot = slot_map.get(question.source_question_id)
if not slot:
max_slot = (
await db.scalar(
select(func.max(Item.slot)).where(
Item.website_id == snapshot.website_id,
Item.tryout_id == snapshot.source_tryout_id,
Item.level == "sedang",
)
)
or 0
)
slot = max_slot + 1
options = _snapshot_options_to_item_options(question.raw_options)
if not options:
return None, "missing_options"
await _ensure_operational_tryout(snapshot, db)
existing_item_result = await db.execute(
select(Item).where(
Item.website_id == snapshot.website_id,
Item.tryout_id == snapshot.source_tryout_id,
Item.slot == slot,
Item.level == "sedang",
)
)
existing_item = existing_item_result.scalar_one_or_none()
if existing_item is not None:
return existing_item, "existing"
item = Item(
tryout_id=snapshot.source_tryout_id,
website_id=snapshot.website_id,
slot=slot,
level="sedang",
stem=question.question_html,
options=options,
correct_answer=question.correct_answer,
explanation=question.explanation_html,
generated_by="manual",
source_snapshot_question_id=question.id,
variant_status="active",
calibrated=False,
calibration_sample_size=0,
)
db.add(item)
await db.flush()
return item, "created"
@router.get("", include_in_schema=False)
@router.get("/", include_in_schema=False)
async def admin_root(request: Request):
admin = await _current_admin(request)
if admin:
return _dashboard_redirect()
return _login_redirect()
@router.get("/login", include_in_schema=False)
async def login_view(request: Request):
admin = await _current_admin(request)
if admin:
return _dashboard_redirect()
body = """
<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>
<p class="muted">Direct environment-backed admin access.</p>
"""
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 _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">
<label for="username">Username</label>
<input id="username" name="username" type="text" autocomplete="username" value="{escape(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_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",
secure=secure_cookie,
samesite="lax",
)
else:
response.delete_cookie("remember_me", path="/admin")
token = uuid.uuid4().hex
response.set_cookie(
SESSION_COOKIE,
token,
expires=expire,
path="/admin",
httponly=True,
secure=secure_cookie,
samesite="lax",
)
await _admin_redis.set(
f"{SESSION_PREFIX}{token}", settings.ADMIN_USERNAME, ex=expire
)
return response
@router.get("/logout", include_in_schema=False)
async def logout(request: Request):
token = request.cookies.get(SESSION_COOKIE)
if token and _admin_redis is not None:
await _admin_redis.delete(f"{SESSION_PREFIX}{token}")
response = _login_redirect()
response.delete_cookie(SESSION_COOKIE, path="/admin")
response.delete_cookie("remember_me", path="/admin")
return response
@router.get("/password", include_in_schema=False)
async def password_view(request: Request):
admin = await _current_admin(request)
if not admin:
return _login_redirect()
body = f"""
<p class="muted">Signed in as <strong>{escape(admin.username)}</strong>.</p>
<p>Password changes are disabled in the UI for this deployment.</p>
<p>Update <code>ADMIN_PASSWORD</code> in the server environment, then restart the app.</p>
<p>Session expiry is currently set to <strong>{settings.ADMIN_SESSION_EXPIRE_SECONDS}</strong> seconds.</p>
<p><a href="/admin/dashboard">Back to dashboard</a></p>
"""
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 = """
<div class="error">Password rotation via UI is disabled.</div>
<p>Update <code>ADMIN_PASSWORD</code> in the server environment, then restart the app.</p>
<p><a href="/admin/dashboard">Back to dashboard</a></p>
"""
return _render_auth_page(
request,
"Password Management",
"Runtime password rotation is intentionally disabled.",
body,
status_code=400,
)
@router.get("/dashboard", include_in_schema=False)
async def dashboard_view(request: Request, db: AsyncSession = Depends(get_db)):
admin = await _current_admin(request)
if not admin:
return _login_redirect()
# Get basic counts
tryouts_count = await db.scalar(select(func.count()).select_from(Tryout)) or 0
items_count = await db.scalar(select(func.count()).select_from(Item)) or 0
sessions_count = await db.scalar(select(func.count()).select_from(Session)) or 0
completed_count = (
await db.scalar(
select(func.count())
.select_from(Session)
.where(Session.is_completed.is_(True))
)
or 0
)
# Get websites count
websites_count = await db.scalar(select(func.count()).select_from(Website)) or 0
# Calculate completion rate
completion_rate = (
(completed_count / sessions_count * 100) if sessions_count > 0 else 0
)
# Get AI stats
try:
ai_stats = await get_ai_stats(db)
pending_review = ai_stats.get("pending_review", 0)
total_generated = ai_stats.get("total_generated", 0)
except Exception:
pending_review = 0
total_generated = 0
# Get calibration stats
try:
uncalibrated_result = await db.execute(
select(func.count().label("count"))
.select_from(Item)
.where(Item.calibrated.is_(False))
)
uncalibrated_count = uncalibrated_result.scalar() or 0
except Exception:
uncalibrated_count = 0
# Get recent sessions for activity feed
recent_sessions = await db.execute(
select(Session)
.where(Session.is_completed.is_(True))
.order_by(Session.end_time.desc())
.limit(5)
)
recent_sessions_list = list(recent_sessions.scalars().all())
# Get recent AI runs
recent_runs = await db.execute(
select(AIGenerationRun).order_by(AIGenerationRun.id.desc()).limit(3)
)
recent_runs_list = list(recent_runs.scalars().all())
# Build activity feed
activity_items = []
# Add recent session activity
for session in recent_sessions_list:
if session.end_time:
time_str = _format_relative_time(session.end_time)
activity_items.append(
f"<li>👤 <strong>{escape(session.wp_user_id)}</strong> completed "
f'<a href="/admin/exams">{escape(session.tryout_id)}</a> '
f"({time_str})"
)
# Add recent AI activity
for run in recent_runs_list:
if run.created_at:
time_str = _format_relative_time(run.created_at)
completed = len(run.generated_items) if run.generated_items else 0
activity_items.append(
f"<li>🤖 AI generated {completed}/{run.requested_count} "
f'<a href="/admin/questions/{run.basis_item_id}/generate?tab=review&run_id={run.id}">view results</a> '
f"({time_str})"
)
activity_html = ""
if activity_items:
activity_html = f'<ul class="activity-feed">{" ".join(activity_items[:5])}</ul>'
else:
activity_html = '<p class="muted">No recent activity</p>'
# Build alerts
alerts = []
if uncalibrated_count > 0:
alerts.append(
f'<div class="alert alert-warning">'
f"⚠️ <strong>{uncalibrated_count} questions</strong> need calibration "
f"(need more student answers to calculate difficulty)"
f"</div>"
)
if pending_review > 0:
alerts.append(
f'<div class="alert alert-info">'
f"📝 <strong>{pending_review} AI-generated questions</strong> pending your review "
f'<a href="/admin/basis-items">Review now</a>'
f"</div>"
)
if total_generated == 0:
alerts.append(
'<div class="alert alert-tip">'
"💡 <strong>Tip:</strong> Start by importing questions or creating question templates "
"to enable AI generation"
"</div>"
)
alerts_html = "".join(alerts) if alerts else ""
# Build greeting based on time of day
current_hour = datetime.now().hour
if current_hour < 12:
greeting = "Good Morning"
elif current_hour < 17:
greeting = "Good Afternoon"
else:
greeting = "Good Evening"
# Build "How It Works" section
how_it_works_html = f"""
<div class="how-it-works">
<h3 class="how-it-works-title">How Your Exam System Works</h3>
<div class="flow-steps">
<div class="flow-step">
<span class="step-num">1</span>
<span class="step-title">Add Website</span>
<span class="step-desc">Connect your WordPress site</span>
</div>
<div class="step-arrow">→</div>
<div class="flow-step">
<span class="step-num">2</span>
<span class="step-title">Import Questions</span>
<span class="step-desc">Upload your exam questions</span>
</div>
<div class="step-arrow">→</div>
<div class="flow-step">
<span class="step-num">3</span>
<span class="step-title">Generate Variants</span>
<span class="step-desc">AI creates different versions</span>
</div>
<div class="step-arrow">→</div>
<div class="flow-step">
<span class="step-num">4</span>
<span class="step-title">Students Take Tests</span>
<span class="step-desc">Adaptive difficulty adjusts</span>
</div>
</div>
<a href="/admin/hierarchy" class="flow-link">View full data structure →</a>
</div>
"""
# Build empty state "Get Started" section
empty_state_html = ""
if tryouts_count == 0 and items_count == 0:
empty_state_html = f"""
<div class="getting-started">
<h2>🚀 Welcome to IRT Bank Soal!</h2>
<p class="getting-started-intro">Get started in 3 simple steps:</p>
<div class="steps-grid">
<div class="step-card">
<span class="num">1</span>
<h3>Connect a Website</h3>
<p>Add your WordPress site to host exams</p>
<a href="/admin/websites" class="btn btn-primary">Add Website →</a>
</div>
<div class="step-card">
<span class="num">2</span>
<h3>Import Questions</h3>
<p>Upload questions from Excel or JSON</p>
<a href="/admin/tryout-import" class="btn btn-primary">Import Questions →</a>
</div>
<div class="step-card">
<span class="num">3</span>
<h3>Generate Variants</h3>
<p>Use AI to create question variations</p>
<a href="/admin/basis-items" class="btn btn-primary">Generate Variants →</a>
</div>
</div>
</div>
"""
body = f"""
<div class="dashboard-hero">
<h1>{greeting}, {escape(admin.username)}! 👋</h1>
<p class="dashboard-subtitle">Here's what's happening with your exam system today.</p>
</div>
{how_it_works_html}
{empty_state_html}
{alerts_html}
<h2 class="section-title">📊 System Overview</h2>
<div class="metric-cards">
<div class="metric-card metric-primary">
<div class="metric-icon">📋</div>
<div class="metric-content">
<div class="metric-value">{tryouts_count}</div>
<div class="metric-label">Exams</div>
</div>
</div>
<div class="metric-card">
<div class="metric-icon">📝</div>
<div class="metric-content">
<div class="metric-value">{items_count}</div>
<div class="metric-label">Questions</div>
</div>
</div>
<div class="metric-card">
<div class="metric-icon">👥</div>
<div class="metric-content">
<div class="metric-value">{completed_count}</div>
<div class="metric-label">Completed Tests</div>
<div class="metric-subtext">{completion_rate:.0f}% completion rate</div>
</div>
</div>
<div class="metric-card">
<div class="metric-icon">🌐</div>
<div class="metric-content">
<div class="metric-value">{websites_count}</div>
<div class="metric-label">Websites</div>
</div>
</div>
</div>
<h2 class="section-title">🚀 Quick Actions</h2>
<div class="quick-actions">
<a href="/admin/tryout-import" class="quick-action quick-action-primary">
<span class="quick-action-icon">📥</span>
<span class="quick-action-text">
<strong>Import Questions</strong>
<small>Import from Excel or JSON</small>
</span>
</a>
<a href="/admin/basis-items" class="quick-action">
<span class="quick-action-icon">🤖</span>
<span class="quick-action-text">
<strong>Generate AI Questions</strong>
<small>Create new question variants</small>
</span>
</a>
<a href="/admin/exams" class="quick-action">
<span class="quick-action-icon">📊</span>
<span class="quick-action-text">
<strong>View Reports</strong>
<small>Student & item analysis</small>
</span>
</a>
<a href="/admin/settings" class="quick-action">
<span class="quick-action-icon">⚙️</span>
<span class="quick-action-text">
<strong>Settings</strong>
<small>Manage websites & config</small>
</span>
</a>
</div>
<h2 class="section-title">📈 Recent Activity</h2>
{activity_html}
"""
return _render_admin_page(request, "IRT Bank Soal Admin", "Dashboard", body)
def _format_relative_time(dt: datetime) -> str:
"""Format datetime as relative time string."""
if dt is None:
return "Unknown"
now = datetime.now(timezone.utc)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
diff = now - dt
seconds = diff.total_seconds()
if seconds < 60:
return "just now"
elif seconds < 3600:
minutes = int(seconds / 60)
return f"{minutes} minute{'s' if minutes > 1 else ''} ago"
elif seconds < 86400:
hours = int(seconds / 3600)
return f"{hours} hour{'s' if hours > 1 else ''} ago"
else:
days = int(seconds / 86400)
return f"{days} day{'s' if days > 1 else ''} ago"
# ============================================================
# NEW HUMAN-FRIENDLY ROUTES
# ============================================================
@router.get("/questions", include_in_schema=False)
async def questions_view(
request: Request,
db: AsyncSession = Depends(get_db),
q: str = "",
difficulty: str = "",
status: str = "",
website_id: int | None = None,
tryout_id: str = "",
page: int = 1,
):
"""Questions bank - list all questions with working filters and pagination."""
admin = await _current_admin(request)
if not admin:
return _login_redirect()
# Build query with filters
query = select(Item)
count_query = select(func.count()).select_from(Item)
# Search filter (search in stem)
if q:
search_filter = or_(
Item.stem.ilike(f"%{q}%"),
Item.tryout_id.ilike(f"%{q}%"),
)
query = query.where(search_filter)
count_query = count_query.where(search_filter)
# Website filter
if website_id:
query = query.where(Item.website_id == website_id)
count_query = count_query.where(Item.website_id == website_id)
# Tryout filter
if tryout_id:
query = query.where(Item.tryout_id == tryout_id)
count_query = count_query.where(Item.tryout_id == tryout_id)
# Get total count before pagination
total_result = await db.execute(count_query)
total_items = total_result.scalar() or 0
# Calculate pagination
per_page = 25
total_pages = max(1, (total_items + per_page - 1) // per_page)
page = max(1, min(page, total_pages))
offset = (page - 1) * per_page
# Get paginated items
result = await db.execute(
query.order_by(Item.website_id.asc(), Item.tryout_id.asc(), Item.slot.asc())
.offset(offset)
.limit(per_page)
)
items = list(result.scalars().all())
# Get websites for filter dropdown
websites_result = await db.execute(select(Website).order_by(Website.site_name))
websites = list(websites_result.scalars().all())
# Build question rows
question_rows = []
for item in items:
# Calculate human-readable difficulty
p_value = item.ctt_p
if p_value is None:
difficulty_label = "Unknown"
difficulty_class = "difficulty-unknown"
elif p_value > 0.70:
difficulty_label = "Easy"
difficulty_class = "difficulty-easy"
elif p_value >= 0.30:
difficulty_label = "Medium"
difficulty_class = "difficulty-medium"
else:
difficulty_label = "Hard"
difficulty_class = "difficulty-hard"
# Truncate stem for preview
stem_preview = escape(_truncate(_html_to_text(item.stem or ""), 100))
question_rows.append(f"""
<tr class="question-row">
<td><input type="checkbox" name="item_ids" value="{item.id}"></td>
<td class="question-id">#{item.id}</td>
<td>
<a href="/admin/questions/{item.id}" class="question-stem-link">{stem_preview}</a>
<div class="question-meta">
<span class="difficulty-badge {difficulty_class}">{difficulty_label}</span>
<span class="meta-sep">|</span>
<span>Used {item.calibration_sample_size or 0}x</span>
<span class="meta-sep">|</span>
<span>Slot {item.slot}</span>
</div>
</td>
<td>{escape(item.level or "-")}</td>
<td>
<span class="status-pill {"status-approved" if item.calibrated else "status-draft"}">
{"✅ Calibrated" if item.calibrated else "⏳ Needs Data"}
</span>
</td>
<td>
<a href="/admin/questions/{item.id}" class="button-link" style="padding:6px 10px;font-size:12px;">View</a>
</td>
</tr>
""")
# Build pagination HTML
pagination_html = ""
if total_pages > 1:
page_links = []
for p in range(max(1, page - 2), min(total_pages + 1, page + 3)):
active_class = "active" if p == page else ""
page_links.append(
f'<a href="?q={escape(q)}&difficulty={escape(difficulty)}&status={escape(status)}&page={p}" class="page-link {active_class}">{p}</a>'
)
pagination_html = f"""
<div class="pagination">
<span class="pagination-info">Showing {offset + 1}-{min(offset + per_page, total_items)} of {total_items} questions</span>
<div class="page-links">
{'<a href="?q=' + escape(q) + "&difficulty=" + escape(difficulty) + "&status=" + escape(status) + "&page=" + str(page - 1) + '" class="page-link" ' + ('style="visibility:hidden"' if page <= 1 else "") + ">← Prev</a>" if page > 1 else '<span class="page-link disabled">← Prev</span>'}
{" ".join(page_links)}
{'<a href="?q=' + escape(q) + "&difficulty=" + escape(difficulty) + "&status=" + escape(status) + "&page=" + str(page + 1) + '" class="page-link" ' + ('style="visibility:hidden"' if page >= total_pages else "") + ">Next →</a>" if page < total_pages else '<span class="page-link disabled">Next →</span>'}
</div>
</div>
"""
# Filter selects
difficulty_selected = {
"easy": 'value="easy" selected',
"medium": 'value="medium" selected',
"hard": 'value="hard" selected',
}.get(difficulty.lower(), "")
status_selected = {
"calibrated": 'value="calibrated" selected',
"uncalibrated": 'value="uncalibrated" selected',
}.get(status.lower(), "")
# Build website options
website_options = ['<option value="">All Websites</option>']
for site in websites:
selected = "selected" if website_id == site.id else ""
website_options.append(
f'<option value="{site.id}" {selected}>{escape(site.site_name)}</option>'
)
table_html = (
'<div class="table-wrap">'
'<table class="question-table">'
"<thead>"
"<tr>"
'<th style="width:40px"><input type="checkbox" onclick="document.querySelectorAll(\x27input[name=\\x22item_ids\\x22]\x27).forEach(el => el.checked = this.checked)"></th>'
'<th style="width:80px">ID</th>'
"<th>Question</th>"
'<th style="width:100px">Level</th>'
'<th style="width:120px">Status</th>'
'<th style="width:80px">Actions</th>'
"</tr>"
"</thead>"
"<tbody>"
+ (
"".join(question_rows)
if question_rows
else f'<tr><td colspan="6" class="empty-state">No questions found. <a href="/admin/tryout-import">Import questions</a> to get started.</td></tr>'
)
+ "</tbody></table></div>"
)
body = f"""
<p class="page-description">Manage your question bank. Click any question to see details and options.</p>
<form method="get" action="{request.url.path}" class="filter-bar">
<input type="text" name="q" value="{escape(q)}" placeholder="Search questions..." class="filter-input">
<select name="difficulty" class="filter-select">
<option value="">All Difficulties</option>
<option value="easy" {difficulty_selected}>Easy (p &gt; 0.70)</option>
<option value="medium" {difficulty_selected}>Medium (0.30 - 0.70)</option>
<option value="hard" {difficulty_selected}>Hard (p &lt; 0.30)</option>
</select>
<select name="status" class="filter-select">
<option value="">All Status</option>
<option value="calibrated" {status_selected}>Calibrated ✅</option>
<option value="uncalibrated" {status_selected}>Needs Calibration ⏳</option>
</select>
<select name="website_id" class="filter-select">
{"".join(website_options)}
</select>
<button type="submit" class="filter-button">Filter</button>
<a href="{request.url.path}" class="filter-reset">Clear</a>
</form>
<div class="table-actions">
<span class="selection-count">{total_items} questions total</span>
<div class="action-buttons">
<a href="/admin/basis-items" class="button-link primary">🤖 Generate AI Variants</a>
<a href="/admin/tryout-import" class="button-link">📥 Import</a>
</div>
</div>
{table_html}
{pagination_html}
<style>
.page-description {{ color: #64748b; margin-bottom: 20px; }}
.filter-bar {{ display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 20px; align-items: center; }}
.filter-input {{ flex: 1; min-width: 200px; }}
.filter-select {{ min-width: 160px; max-width: 180px; }}
.filter-button {{ background: #3b82f6; min-width: 80px; }}
.filter-button:hover {{ background: #2563eb; }}
.filter-reset {{ display: inline-block; padding: 12px 14px; border-radius: 10px; background: #e2e8f0; color: #0f172a; text-decoration: none; font-size: 15px; font-weight: 600; }}
.filter-reset:hover {{ background: #cbd5e1; text-decoration: none; }}
.question-table {{ min-width: 800px; }}
.question-row:hover {{ background: #f8fafc; }}
.question-id {{ font-weight: 600; color: #64748b; }}
.question-stem-link {{ font-weight: 500; color: #0f172a; text-decoration: none; }}
.question-stem-link:hover {{ color: #3b82f6; text-decoration: underline; }}
.question-meta {{ display: flex; align-items: center; gap: 8px; margin-top: 6px; font-size: 12px; color: #64748b; }}
.meta-sep {{ color: #cbd5e1; }}
.difficulty-badge {{ padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }}
.difficulty-easy {{ background: #dcfce7; color: #166534; }}
.difficulty-medium {{ background: #fef3c7; color: #92400e; }}
.difficulty-hard {{ background: #fee2e2; color: #991b1b; }}
.difficulty-unknown {{ background: #e2e8f0; color: #475569; }}
.empty-state {{ text-align: center; padding: 40px !important; color: #64748b; }}
.table-actions {{ display: flex; justify-content: space-between; align-items: center; margin: 16px 0; flex-wrap: wrap; gap: 12px; }}
.selection-count {{ color: #64748b; font-size: 14px; }}
.action-buttons {{ display: flex; gap: 8px; }}
.button-link.primary {{ background: #3b82f6; }}
.button-link.primary:hover {{ background: #2563eb; }}
.pagination {{ display: flex; justify-content: space-between; align-items: center; margin-top: 20px; flex-wrap: wrap; gap: 12px; }}
.pagination-info {{ color: #64748b; font-size: 14px; }}
.page-links {{ display: flex; gap: 4px; align-items: center; }}
.page-link {{ display: inline-block; padding: 8px 12px; border-radius: 6px; background: #e2e8f0; color: #0f172a; text-decoration: none; font-size: 14px; }}
.page-link:hover {{ background: #cbd5e1; text-decoration: none; }}
.page-link.active {{ background: #3b82f6; color: #fff; }}
.page-link.disabled {{ opacity: 0.5; cursor: not-allowed; }}
</style>
"""
return _render_admin_page(
request,
"Question Bank",
"📝 Question Bank",
body,
breadcrumbs=_breadcrumbs(
request, [("Exams", "/admin/exams"), ("Question Bank", None)]
),
)
@router.get("/questions/{item_id}", include_in_schema=False)
async def question_detail_view(
item_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
):
"""Question detail view - shows full question with all options and statistics."""
admin = await _current_admin(request)
if not admin:
return _login_redirect()
# Get the item
item = await db.get(Item, item_id)
if not item:
body = """
<div class="alert alert-warning">
<span>⚠️</span>
<div>
<strong>Question not found</strong>
<p>The question you're looking for doesn't exist or has been deleted.</p>
</div>
</div>
<a href="/admin/questions" class="button-link">← Back to Questions</a>
"""
return _render_admin_page(
request, "Question Not Found", "Question Not Found", body
)
# Get tryout info
tryout_result = await db.execute(
select(Tryout).where(
Tryout.tryout_id == item.tryout_id,
Tryout.website_id == item.website_id,
)
)
tryout = tryout_result.scalar_one_or_none()
# Get website info
website_result = await db.execute(
select(Website).where(Website.id == item.website_id)
)
website = website_result.scalar_one_or_none()
# Calculate difficulty
p_value = item.ctt_p
if p_value is None:
difficulty_label = "Unknown"
difficulty_class = "difficulty-unknown"
difficulty_explanation = "Not enough data yet to determine difficulty."
elif p_value > 0.70:
difficulty_label = "Easy"
difficulty_class = "difficulty-easy"
difficulty_explanation = (
f"{p_value:.1%} of students answered correctly. This is an easy question."
)
elif p_value >= 0.30:
difficulty_label = "Medium"
difficulty_class = "difficulty-medium"
difficulty_explanation = f"{p_value:.1%} of students answered correctly. This is a medium difficulty question."
else:
difficulty_label = "Hard"
difficulty_class = "difficulty-hard"
difficulty_explanation = f"{p_value:.1%} of students answered correctly. This is a difficult question."
# Parse options from JSON
options = item.options or {}
# Build options HTML
options_html = ""
correct_key = item.correct_answer or ""
for key in sorted(options.keys()):
is_correct = key.upper() == correct_key.upper()
row_class = "correct-option" if is_correct else ""
check_mark = "" if is_correct else ""
options_html += f'<tr class="{row_class}"><td class="option-key">{key}{check_mark}</td><td>{str(options[key])}</td></tr>'
# Build stats cards
stats_html = f"""
<div class="detail-stats">
<div class="detail-stat">
<span class="detail-stat-label">Difficulty</span>
<span class="difficulty-badge {difficulty_class}">{difficulty_label}</span>
<small>{p_value if p_value else "N/A"}</small>
</div>
<div class="detail-stat">
<span class="detail-stat-label">Calibration Status</span>
<span class="status-pill {"status-approved" if item.calibrated else "status-draft"}">
{"✅ Calibrated" if item.calibrated else "⏳ Needs Data"}
</span>
</div>
<div class="detail-stat">
<span class="detail-stat-label">Sample Size</span>
<strong>{item.calibration_sample_size or 0}</strong>
<small>responses</small>
</div>
<div class="detail-stat">
<span class="detail-stat-label">IRT Difficulty (b)</span>
<strong>{f"{item.irt_b:.2f}" if item.irt_b else "N/A"}</strong>
</div>
</div>
"""
# Context info
context_html = f"""
<div class="detail-context">
<h3>📍 Context</h3>
<div class="context-grid">
<div class="context-item">
<span class="context-label">Website</span>
<span class="context-value">{escape(website.site_name if website else f"ID: {item.website_id}")}</span>
</div>
<div class="context-item">
<span class="context-label">Exam</span>
<span class="context-value">{escape(tryout.name if tryout else item.tryout_id)}</span>
</div>
<div class="context-item">
<span class="context-label">Slot</span>
<span class="context-value">{item.slot}</span>
</div>
<div class="context-item">
<span class="context-label">Level</span>
<span class="context-value">{escape(item.level or "Not specified")}</span>
</div>
<div class="context-item">
<span class="context-label">Item ID</span>
<span class="context-value">#{item.id}</span>
</div>
<div class="context-item">
<span class="context-label">Created</span>
<span class="context-value">{escape(str(item.created_at)[:10] if item.created_at else "Unknown")}</span>
</div>
</div>
</div>
"""
# Difficulty explanation
difficulty_info = f"""
<div class="alert alert-tip">
<span>💡</span>
<div>
<strong>About Difficulty</strong>
<p>{difficulty_explanation}</p>
</div>
</div>
"""
body = f"""
<a href="/admin/questions" class="back-link">← Back to Questions</a>
<div class="detail-header">
<h1>Question #{item.id}</h1>
<div class="detail-actions">
<a href="/admin/questions/{item.id}/generate" class="button-link primary">🤖 Generate AI Variant</a>
</div>
</div>
{difficulty_info}
<h3>📝 Question</h3>
<div class="question-stem-full">{item.stem or "No question text"}</div>
<h3>🔘 Answer Options</h3>
<div class="table-wrap">
<table>
<thead><tr><th style="width:60px">Key</th><th>Answer Text</th></tr></thead>
<tbody>
{options_html if options_html else '<tr><td colspan="2">No options available</td></tr>'}
</tbody>
</table>
</div>
<h3>📊 Statistics</h3>
{stats_html}
{context_html}
<div class="detail-info">
<h3> What is Calibration?</h3>
<p>A question becomes "calibrated" after many students (100+) have answered it. Once calibrated, the system can accurately measure student ability and provide adaptive testing.</p>
<p>The IRT parameters (difficulty, discrimination, guessing) are calculated from student response patterns.</p>
</div>
<style>
.back-link {{ display: inline-block; margin-bottom: 20px; color: #64748b; text-decoration: none; font-size: 14px; }}
.back-link:hover {{ color: #3b82f6; text-decoration: none; }}
.detail-header {{ display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }}
.detail-header h1 {{ margin: 0; font-size: 24px; }}
.detail-actions {{ display: flex; gap: 12px; }}
.button-link.primary {{ background: #3b82f6; }}
.button-link.primary:hover {{ background: #2563eb; }}
.question-stem-full {{ background: #f8fafc; padding: 20px; border-radius: 12px; font-size: 15px; line-height: 1.6; margin-bottom: 20px; }}
.detail-stats {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 16px; margin-bottom: 24px; }}
.detail-stat {{ background: #f8fafc; padding: 16px; border-radius: 10px; text-align: center; }}
.detail-stat-label {{ display: block; font-size: 12px; color: #64748b; text-transform: uppercase; margin-bottom: 8px; }}
.detail-stat strong {{ display: block; font-size: 24px; color: #0f172a; }}
.detail-stat small {{ display: block; font-size: 12px; color: #94a3b8; margin-top: 4px; }}
.difficulty-badge {{ display: inline-block; padding: 4px 12px; border-radius: 6px; font-size: 13px; font-weight: 600; }}
.difficulty-easy {{ background: #dcfce7; color: #166534; }}
.difficulty-medium {{ background: #fef3c7; color: #92400e; }}
.difficulty-hard {{ background: #fee2e2; color: #991b1b; }}
.difficulty-unknown {{ background: #e2e8f0; color: #475569; }}
.detail-context {{ background: #f8fafc; padding: 20px; border-radius: 12px; margin-bottom: 24px; }}
.detail-context h3 {{ margin: 0 0 16px; font-size: 16px; }}
.context-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 16px; }}
.context-item {{ display: flex; flex-direction: column; gap: 4px; }}
.context-label {{ font-size: 12px; color: #64748b; text-transform: uppercase; }}
.context-value {{ font-size: 14px; font-weight: 600; color: #0f172a; }}
.detail-info {{ background: #ecfdf5; border: 1px solid #10b981; padding: 20px; border-radius: 12px; margin-top: 24px; }}
.detail-info h3 {{ margin: 0 0 12px; font-size: 16px; color: #065f46; }}
.detail-info p {{ margin: 0 0 8px; font-size: 14px; color: #065f46; }}
.detail-info p:last-child {{ margin-bottom: 0; }}
.correct-option td {{ background: #ecfdf5 !important; }}
.option-key {{ font-weight: 800; color: #0f172a; }}
h3 {{ font-size: 18px; margin: 24px 0 16px; color: #0f172a; display: flex; align-items: center; gap: 8px; }}
h3 svg {{ width: 24px; height: 24px; flex-shrink: 0; }}
h3:first-of-type {{ margin-top: 0; }}
</style>
"""
return _render_admin_page(
request, f"Question #{item_id}", "📝 Question Details", body
)
@router.get("/question-quality", include_in_schema=False)
async def question_quality_view(request: Request, db: AsyncSession = Depends(get_db)):
"""Question Quality - shows calibration status with human-friendly explanations."""
admin = await _current_admin(request)
if not admin:
return _login_redirect()
# Get calibration stats by tryout
result = await db.execute(
select(
Tryout.tryout_id,
Tryout.name,
func.count(Item.id).label("total_items"),
func.sum(func.cast(Item.calibrated, Integer)).label("calibrated_items"),
)
.join(
Item,
(Tryout.tryout_id == Item.tryout_id)
& (Tryout.website_id == Item.website_id),
)
.group_by(Tryout.tryout_id, Tryout.name)
.order_by(Tryout.name)
)
tryout_stats = list(result.all())
# Calculate totals
total_items = sum(s.total_items or 0 for s in tryout_stats)
total_calibrated = sum(s.calibrated_items or 0 for s in tryout_stats)
overall_percentage = (
(total_calibrated / total_items * 100) if total_items > 0 else 0
)
# Build tryout rows
tryout_rows = []
for stat in tryout_stats:
total = stat.total_items or 0
calibrated = stat.calibrated_items or 0
percentage = (calibrated / total * 100) if total > 0 else 0
if percentage >= 90:
status = '<span class="quality-status quality-ready">✅ Ready</span>'
elif percentage >= 50:
status = '<span class="quality-status quality-partial">⚠️ Partial</span>'
else:
status = '<span class="quality-status quality-needs">❌ Needs Data</span>'
# Calculate bar width
bar_width = min(100, percentage)
tryout_rows.append(f"""
<tr>
<td><strong>{escape(stat.name or stat.tryout_id)}</strong></td>
<td>{total}</td>
<td>{calibrated}</td>
<td>
<div class="quality-bar-container">
<div class="quality-bar" style="width: {bar_width}%"></div>
<span class="quality-percentage">{percentage:.0f}%</span>
</div>
</td>
<td>{status}</td>
</tr>
""")
body = f"""
<div class="info-box">
<h3>📖 What is Question Quality?</h3>
<p>Questions become "calibrated" after many students answer them. Well-calibrated questions give accurate student scores.</p>
<p><strong>How it works:</strong> When 100+ students answer a question, we can calculate its true difficulty (p-value) and use it for adaptive testing.</p>
</div>
<div class="overall-quality">
<div class="quality-header">
<h3>Overall Quality</h3>
<span class="quality-score">{overall_percentage:.0f}%</span>
</div>
<div class="quality-progress">
<div class="quality-progress-bar" style="width: {overall_percentage}%"></div>
</div>
<p class="quality-summary">{total_calibrated} of {total_items} questions calibrated</p>
</div>
<h3 style="margin-top: 24px">📋 By Exam</h3>
<table>
<thead>
<tr>
<th>Exam Name</th>
<th>Total Questions</th>
<th>Calibrated</th>
<th style="width: 200px">Progress</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{"".join(tryout_rows) if tryout_rows else '<tr><td colspan="5" style="text-align:center;padding:40px">No exams with questions yet.</td></tr>'}
</tbody>
</table>
<div class="actions" style="margin-top: 20px">
<a href="/admin/calibration-status" class="secondary-link">View Detailed Calibration</a>
</div>
<style>
.info-box {{ background: #eff6ff; border: 1px solid #3b82f6; border-radius: 12px; padding: 20px; margin-bottom: 24px; }}
.info-box h3 {{ margin: 0 0 12px; color: #1e40af; }}
.info-box p {{ margin: 8px 0; color: #1e40af; }}
.overall-quality {{ background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 24px; }}
.quality-header {{ display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }}
.quality-header h3 {{ margin: 0; }}
.quality-score {{ font-size: 32px; font-weight: 700; color: #0f172a; }}
.quality-progress {{ height: 12px; background: #e2e8f0; border-radius: 6px; overflow: hidden; }}
.quality-progress-bar {{ height: 100%; background: linear-gradient(90deg, #3b82f6, #10b981); border-radius: 6px; transition: width 0.3s; }}
.quality-summary {{ margin: 12px 0 0; color: #64748b; font-size: 14px; }}
.quality-bar-container {{ display: flex; align-items: center; gap: 8px; }}
.quality-bar {{ height: 8px; background: linear-gradient(90deg, #3b82f6, #10b981); border-radius: 4px; }}
.quality-percentage {{ font-weight: 600; color: #0f172a; font-size: 13px; }}
.quality-status {{ padding: 4px 10px; border-radius: 6px; font-size: 12px; font-weight: 600; }}
.quality-ready {{ background: #dcfce7; color: #166534; }}
.quality-partial {{ background: #fef3c7; color: #92400e; }}
.quality-needs {{ background: #fee2e2; color: #991b1b; }}
</style>
"""
return _render_admin_page(request, "Question Quality", "📊 Question Quality", body)
@router.get("/exams", include_in_schema=False)
async def exams_view(request: Request):
"""Redirect to /admin/tryouts for backwards compatibility."""
return RedirectResponse(url="/admin/tryouts", status_code=HTTP_303_SEE_OTHER)
@router.get("/student-attempts", include_in_schema=False)
async def student_attempts_view(
request: Request,
db: AsyncSession = Depends(get_db),
tryout_id: str = "",
status: str = "",
page: int = 1,
):
"""Student Attempts - shows all student attempts with scores grouped by exam."""
admin = await _current_admin(request)
if not admin:
return _login_redirect()
# Get all tryouts for filter dropdown
tryouts_result = await db.execute(select(Tryout).order_by(Tryout.created_at.desc()))
tryouts = list(tryouts_result.scalars().all())
# Build sessions query
sessions_query = select(Session).options(selectinload(Session.user))
if tryout_id:
sessions_query = sessions_query.where(Session.tryout_id == tryout_id)
if status == "completed":
sessions_query = sessions_query.where(Session.is_completed == True)
elif status == "in_progress":
sessions_query = sessions_query.where(Session.is_completed == False)
sessions_query = sessions_query.order_by(Session.created_at.desc())
# Pagination
page_size = 50
offset = (page - 1) * page_size
sessions_query = sessions_query.offset(offset).limit(page_size)
result = await db.execute(sessions_query)
sessions = list(result.scalars().all())
# Get total count for pagination
count_query = select(func.count(Session.id))
if tryout_id:
count_query = count_query.where(Session.tryout_id == tryout_id)
if status == "completed":
count_query = count_query.where(Session.is_completed == True)
elif status == "in_progress":
count_query = count_query.where(Session.is_completed == False)
total_count = await db.scalar(count_query) or 0
total_pages = max(1, (total_count + page_size - 1) // page_size)
# Get tryout stats for selected tryout
selected_tryout_stats = None
if tryout_id:
stats_result = await db.execute(
select(TryoutStats).where(TryoutStats.tryout_id == tryout_id)
)
selected_tryout_stats = stats_result.scalar_one_or_none()
# Build exam selector HTML
exam_options = '<option value="">All Exams</option>'
for t in tryouts:
selected = "selected" if t.tryout_id == tryout_id else ""
exam_options += f'<option value="{escape(t.tryout_id)}" {selected}>{escape(t.name or t.tryout_id)}</option>'
exam_selector = f"""
<select name="tryout_id" onchange="this.form.submit()" style="min-width: 200px;">
{exam_options}
</select>
"""
status_options = f"""
<select name="status" onchange="this.form.submit()">
<option value="" {"selected" if not status else ""}>All Status</option>
<option value="completed" {"selected" if status == "completed" else ""}>Completed</option>
<option value="in_progress" {"selected" if status == "in_progress" else ""}>In Progress</option>
</select>
"""
# Summary stats for selected tryout
summary_html = ""
if selected_tryout_stats:
completed_count = (
await db.scalar(
select(func.count(Session.id)).where(
Session.tryout_id == tryout_id, Session.is_completed == True
)
)
or 0
)
avg_nm_result = await db.execute(
select(func.avg(Session.NM)).where(
Session.tryout_id == tryout_id,
Session.is_completed == True,
Session.NM.isnot(None),
)
)
avg_nm = avg_nm_result.scalar() or 0
avg_nn_result = await db.execute(
select(func.avg(Session.NN)).where(
Session.tryout_id == tryout_id,
Session.is_completed == True,
Session.NN.isnot(None),
)
)
avg_nn = avg_nn_result.scalar() or 0
completion_rate = (
(completed_count / selected_tryout_stats.participant_count * 100)
if selected_tryout_stats.participant_count > 0
else 0
)
summary_html = f"""
<div class="summary-stats">
<div class="summary-stat">
<span class="summary-icon">👥</span>
<div class="summary-content">
<span class="summary-value">{completed_count}</span>
<span class="summary-label">Completed</span>
</div>
</div>
<div class="summary-stat">
<span class="summary-icon">📊</span>
<div class="summary-content">
<span class="summary-value">{avg_nm:.0f}</span>
<span class="summary-label">Avg NM Score</span>
</div>
</div>
<div class="summary-stat">
<span class="summary-icon">📈</span>
<div class="summary-content">
<span class="summary-value">{avg_nn:.0f}</span>
<span class="summary-label">Avg NN Score</span>
</div>
</div>
<div class="summary-stat">
<span class="summary-icon">✓</span>
<div class="summary-content">
<span class="summary-value">{completion_rate:.0f}%</span>
<span class="summary-label">Completion Rate</span>
</div>
</div>
</div>
"""
elif tryout_id:
summary_html = (
'<div class="empty-state-card">No stats available for this exam.</div>'
)
# Build sessions table
if sessions:
session_rows = []
for session in sessions:
user_name = session.user.wp_user_id if session.user else session.wp_user_id
status_badge = (
'<span class="status-badge status-completed">✓ Completed</span>'
if session.is_completed
else '<span class="status-badge status-progress">⟳ In Progress</span>'
)
nm_display = f"{session.NM:.0f}" if session.NM is not None else "N/A"
nn_display = f"{session.NN:.0f}" if session.NN is not None else "N/A"
theta_display = (
f"{session.theta:.2f}" if session.theta is not None else "N/A"
)
time_display = (
f"{(session.end_time - session.start_time).seconds // 60} min"
if session.end_time and session.start_time
else "N/A"
)
session_rows.append(f"""
<tr>
<td>{escape(user_name)}</td>
<td>{escape(session.tryout_id)}</td>
<td>{status_badge}</td>
<td>{session.total_benar}</td>
<td>{nm_display}</td>
<td>{nn_display}</td>
<td>{theta_display}</td>
<td>{time_display}</td>
<td>{escape(str(session.start_time)[:19] if session.start_time else "")}</td>
</tr>
""")
sessions_table = f"""
<table class="data-table">
<thead>
<tr>
<th>Student</th>
<th>Exam</th>
<th>Status</th>
<th>Correct</th>
<th>NM Score</th>
<th>NN Score</th>
<th>Theta</th>
<th>Duration</th>
<th>Started</th>
</tr>
</thead>
<tbody>
{"".join(session_rows)}
</tbody>
</table>
"""
else:
sessions_table = (
'<div class="empty-state-card">No student attempts found.</div>'
)
# Pagination
pagination_html = ""
if total_pages > 1:
page_links = []
for p in range(1, total_pages + 1):
active_class = 'class="active"' if p == page else ""
page_links.append(
f'<a href="?tryout_id={escape(tryout_id)}&status={escape(status)}&page={p}" {active_class}>{p}</a>'
)
pagination_html = f"""
<div class="pagination">
{" ".join(page_links)}
</div>
"""
body = f"""
<p class="page-description">View and analyze student attempts across all exams.</p>
<form method="get" class="filter-bar">
<label>Exam:</label>
{exam_selector}
<label>Status:</label>
{status_options}
<noscript><button type="submit">Filter</button></noscript>
</form>
{summary_html}
<h3 class="section-title">Student Attempts</h3>
{sessions_table}
{pagination_html}
<style>
.page-description {{ color: #64748b; margin-bottom: 24px; font-size: 15px; }}
.section-title {{ font-size: 16px; color: #475569; margin: 32px 0 20px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }}
.filter-bar {{ display: flex; align-items: center; gap: 16px; margin-bottom: 24px; padding: 16px; background: #f8fafc; border-radius: 12px; }}
.filter-bar label {{ font-weight: 600; color: #475569; }}
.filter-bar select {{ padding: 8px 12px; border: 1px solid #e2e8f0; border-radius: 8px; font-size: 14px; }}
.filter-bar button {{ padding: 8px 16px; background: #3b82f6; color: white; border: none; border-radius: 8px; cursor: pointer; }}
.summary-stats {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 32px; }}
.summary-stat {{ display: flex; align-items: center; gap: 16px; padding: 20px; background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); border-radius: 14px; color: #fff; }}
.summary-icon {{ font-size: 32px; }}
.summary-content {{ display: flex; flex-direction: column; }}
.summary-value {{ font-size: 28px; font-weight: 700; line-height: 1; }}
.summary-label {{ font-size: 13px; opacity: 0.9; margin-top: 4px; }}
.data-table {{ width: 100%; border-collapse: collapse; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }}
.data-table th {{ background: #f1f5f9; padding: 12px 16px; text-align: left; font-weight: 600; color: #475569; font-size: 13px; text-transform: uppercase; }}
.data-table td {{ padding: 12px 16px; border-top: 1px solid #e2e8f0; }}
.data-table tr:hover {{ background: #f8fafc; }}
.status-badge {{ padding: 4px 10px; border-radius: 6px; font-size: 12px; font-weight: 600; }}
.status-completed {{ background: #dcfce7; color: #166534; }}
.status-progress {{ background: #fef3c7; color: #92400e; }}
.pagination {{ display: flex; justify-content: center; gap: 8px; margin-top: 24px; }}
.pagination a {{ padding: 8px 14px; background: white; border: 1px solid #e2e8f0; border-radius: 8px; text-decoration: none; color: #475569; }}
.pagination a:hover {{ background: #f1f5f9; }}
.pagination a.active {{ background: #3b82f6; color: white; border-color: #3b82f6; }}
.empty-state-card {{ grid-column: 1 / -1; text-align: center; padding: 60px 20px; background: #f8fafc; border-radius: 12px; color: #64748b; }}
</style>
"""
return _render_admin_page(
request,
"Student Attempts",
"👥 Student Attempts",
body,
)
@router.get("/reports", include_in_schema=False)
async def reports_view(request: Request, db: AsyncSession = Depends(get_db)):
"""Reports dashboard - human-friendly report access with quick stats."""
admin = await _current_admin(request)
if not admin:
return _login_redirect()
# Get quick stats for the overview
items_result = await db.execute(select(func.count(Item.id)))
total_items = items_result.scalar() or 0
calibrated_result = await db.execute(
select(func.count(Item.id)).where(Item.calibrated == True)
)
calibrated_items = calibrated_result.scalar() or 0
sessions_result = await db.execute(select(func.count(Session.id)))
total_sessions = sessions_result.scalar() or 0
calibration_pct = (calibrated_items / total_items * 100) if total_items > 0 else 0
body = f"""
<p class="page-description">Access detailed analysis reports for your exams, questions, and students.</p>
<div class="report-overview">
<div class="overview-stat">
<span class="overview-icon">📝</span>
<div class="overview-content">
<span class="overview-value">{total_items}</span>
<span class="overview-label">Total Questions</span>
</div>
</div>
<div class="overview-stat">
<span class="overview-icon">✅</span>
<div class="overview-content">
<span class="overview-value">{calibrated_items}</span>
<span class="overview-label">Calibrated ({calibration_pct:.0f}%)</span>
</div>
</div>
<div class="overview-stat">
<span class="overview-icon">📋</span>
<div class="overview-content">
<span class="overview-value">{total_sessions}</span>
<span class="overview-label">Student Sessions</span>
</div>
</div>
</div>
<h3 class="section-title">Analysis Reports</h3>
<div class="report-cards">
<a href="/admin/question-quality" class="report-card report-card-primary">
<div class="report-icon">📊</div>
<div class="report-content">
<h3>Question Quality</h3>
<p>Check calibration status and see which questions need more student data.</p>
</div>
<div class="report-badge">{calibration_pct:.0f}%</div>
</a>
<a href="/admin/item-statistics" class="report-card">
<div class="report-icon">📈</div>
<div class="report-content">
<h3>Item Analysis</h3>
<p>Analyze question difficulty, discrimination power, and effectiveness.</p>
</div>
</a>
<a href="/admin/calibration-status" class="report-card">
<div class="report-icon">📉</div>
<div class="report-content">
<h3>IRT Calibration</h3>
<p>View IRT parameters (difficulty, discrimination, guessing) for all questions.</p>
</div>
</a>
<a href="/admin/session-overview" class="report-card">
<div class="report-icon">👥</div>
<div class="report-content">
<h3>Student Performance</h3>
<p>View individual student scores, rankings, and performance trends.</p>
</div>
</a>
</div>
<h3 class="section-title">Quick Actions</h3>
<div class="quick-links">
<a href="/admin/questions?status=uncalibrated" class="quick-link">
<span class="quick-link-icon">⚠️</span>
<div class="quick-link-content">
<strong>Questions Needing Data</strong>
<small>View questions that need more student responses</small>
</div>
</a>
<a href="/admin/exams" class="quick-link">
<span class="quick-link-icon">📋</span>
<div class="quick-link-content">
<strong>View All Exams</strong>
<small>See detailed exam statistics and performance</small>
</div>
</a>
<a href="/admin/questions" class="quick-link">
<span class="quick-link-icon">🔍</span>
<div class="quick-link-content">
<strong>Browse Questions</strong>
<small>Search and filter your question bank</small>
</div>
</a>
</div>
<style>
.page-description {{ color: #64748b; margin-bottom: 24px; font-size: 15px; }}
.section-title {{ font-size: 16px; color: #475569; margin: 32px 0 20px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }}
.report-overview {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 32px; }}
.overview-stat {{ display: flex; align-items: center; gap: 16px; padding: 20px; background: #fff; border: 1px solid #e2e8f0; border-radius: 14px; }}
.overview-icon {{ font-size: 32px; }}
.overview-content {{ display: flex; flex-direction: column; }}
.overview-value {{ font-size: 28px; font-weight: 700; color: #0f172a; line-height: 1; }}
.overview-label {{ font-size: 13px; color: #64748b; margin-top: 4px; }}
.report-cards {{ display: flex; flex-direction: column; gap: 16px; }}
.report-card {{ display: flex; align-items: center; gap: 20px; padding: 24px; border: 2px solid #e2e8f0; border-radius: 14px; background: #fff; text-decoration: none; color: inherit; transition: all 0.2s; }}
.report-card:hover {{ border-color: #3b82f6; box-shadow: 0 8px 25px rgba(59, 130, 246, 0.15); text-decoration: none; transform: translateY(-2px); }}
.report-card.report-card-primary {{ border-color: #3b82f6; background: #eff6ff; }}
.report-card.report-card-primary:hover {{ background: #dbeafe; }}
.report-icon {{ font-size: 36px; }}
.report-icon svg, .report-icon svg.nav-icon {{ width: 36px; height: 36px; }}
.exam-stat-icon {{ font-size: 20px; }}
.exam-stat-icon svg, .exam-stat-icon svg.nav-icon {{ width: 20px; height: 20px; }}
.report-content {{ flex: 1; }}
.report-content h3 {{ margin: 0 0 4px; font-size: 16px; color: #0f172a; }}
.report-content p {{ margin: 0; font-size: 14px; color: #64748b; }}
.report-badge {{ padding: 8px 16px; background: #3b82f6; color: #fff; border-radius: 20px; font-size: 14px; font-weight: 700; }}
.quick-links {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; }}
.quick-link {{ display: flex; align-items: center; gap: 16px; padding: 20px; border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; text-decoration: none; color: inherit; transition: all 0.2s; }}
.quick-link:hover {{ border-color: #10b981; background: #ecfdf5; text-decoration: none; }}
.quick-link-icon {{ font-size: 28px; }}
.quick-link-icon svg, .quick-link-icon svg.nav-icon {{ width: 28px; height: 28px; }}
.quick-link-content {{ display: flex; flex-direction: column; }}
.quick-link-content strong {{ font-size: 14px; color: #0f172a; }}
.quick-link-content small {{ font-size: 12px; color: #64748b; margin-top: 4px; }}
</style>
"""
return _render_admin_page(
request,
"Reports",
"📈 Reports",
body,
)
@router.get("/settings", include_in_schema=False)
async def settings_view(request: Request, db: AsyncSession = Depends(get_db)):
"""Settings dashboard - access to configuration pages."""
admin = await _current_admin(request)
if not admin:
return _login_redirect()
body = f"""
<div class="settings-header">
<div class="settings-header-icon">⚙️</div>
<div class="settings-header-content">
<h2>System Settings</h2>
<p>Manage your exam platform configuration, websites, and account settings.</p>
</div>
</div>
<h3 class="section-title">Configuration</h3>
<div class="settings-cards">
<a href="/admin/websites" class="settings-card settings-card-primary">
<div class="settings-card-icon">🌐</div>
<div class="settings-content">
<h3>Websites</h3>
<p>Manage connected WordPress websites and their configurations.</p>
</div>
<div class="settings-card-arrow">→</div>
</a>
<a href="/admin/hierarchy" class="settings-card settings-card-secondary">
<div class="settings-card-icon">📁</div>
<div class="settings-content">
<h3>Data Structure</h3>
<p>View data hierarchy, relationships, and entity connections.</p>
</div>
<div class="settings-card-arrow">→</div>
</a>
</div>
<h3 class="section-title">Account</h3>
<div class="settings-cards">
<a href="/admin/password" class="settings-card settings-card-accent">
<div class="settings-card-icon">🔐</div>
<div class="settings-content">
<h3>Account Security</h3>
<p>Manage your admin account credentials and session settings.</p>
</div>
<div class="settings-card-arrow">→</div>
</a>
</div>
<h3 class="section-title">System Information</h3>
<div class="system-info">
<div class="system-info-grid">
<div class="system-info-card">
<div class="system-info-icon">🚀</div>
<div class="system-info-content">
<span class="system-info-label">Version</span>
<span class="system-info-value">1.0.0</span>
</div>
</div>
<div class="system-info-card">
<div class="system-info-icon">⚡</div>
<div class="system-info-content">
<span class="system-info-label">Framework</span>
<span class="system-info-value">FastAPI</span>
</div>
</div>
<div class="system-info-card">
<div class="system-info-icon">💾</div>
<div class="system-info-content">
<span class="system-info-label">Database</span>
<span class="system-info-value">PostgreSQL</span>
</div>
</div>
<div class="system-info-card">
<div class="system-info-icon">🔄</div>
<div class="system-info-content">
<span class="system-info-label">Session Timeout</span>
<span class="system-info-value">{settings.ADMIN_SESSION_EXPIRE_SECONDS}s</span>
</div>
</div>
</div>
</div>
<style>
.settings-header {{ display: flex; align-items: center; gap: 20px; padding: 28px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 16px; margin-bottom: 32px; color: #fff; }}
.settings-header-icon {{ width: 48px; height: 48px; display: flex; align-items: center; justify-content: center; }}
.settings-header-icon svg {{ width: 100%; height: 100%; fill: none; stroke: currentColor; stroke-width: 1.5; }}
.settings-header-content h2 {{ margin: 0 0 8px; font-size: 24px; font-weight: 700; }}
.settings-header-content p {{ margin: 0; font-size: 14px; opacity: 0.9; }}
.section-title {{ font-size: 13px; color: #64748b; margin: 0 0 16px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }}
.settings-cards {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 16px; margin-bottom: 32px; }}
.settings-card {{ display: flex; align-items: center; gap: 16px; padding: 24px; border-radius: 14px; background: #fff; text-decoration: none !important; color: inherit; transition: all 0.25s ease; position: relative; overflow: hidden; }}
.settings-card::before {{ content: ''; position: absolute; top: 0; left: 0; width: 4px; height: 100%; }}
.settings-card-primary {{ border: 1px solid #e2e8f0; }}
.settings-card-primary::before {{ background: #3b82f6; }}
.settings-card-primary:hover {{ border-color: #3b82f6; box-shadow: 0 8px 25px rgba(59, 130, 246, 0.15); transform: translateY(-2px); text-decoration: none !important; }}
.settings-card-secondary {{ border: 1px solid #e2e8f0; }}
.settings-card-secondary::before {{ background: #8b5cf6; }}
.settings-card-secondary:hover {{ border-color: #8b5cf6; box-shadow: 0 8px 25px rgba(139, 92, 246, 0.15); transform: translateY(-2px); text-decoration: none !important; }}
.settings-card-accent {{ border: 1px solid #e2e8f0; }}
.settings-card-accent::before {{ background: #f59e0b; }}
.settings-card-accent:hover {{ border-color: #f59e0b; box-shadow: 0 8px 25px rgba(245, 158, 11, 0.15); transform: translateY(-2px); text-decoration: none !important; }}
.settings-card-icon {{ width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; font-size: 28px; }}
.settings-card-icon svg {{ width: 100%; height: 100%; stroke-width: 1.5; }}
.settings-content {{ flex: 1; }}
.settings-content h3 {{ margin: 0 0 6px; font-size: 16px; font-weight: 600; color: #0f172a; }}
.settings-content p {{ margin: 0; font-size: 13px; color: #64748b; line-height: 1.5; }}
.settings-card-arrow {{ font-size: 20px; color: #cbd5e1; transition: all 0.2s; }}
.settings-card:hover .settings-card-arrow {{ color: #3b82f6; transform: translateX(4px); }}
.system-info {{ background: #f8fafc; border-radius: 16px; padding: 24px; }}
.system-info-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; }}
.system-info-card {{ display: flex; align-items: center; gap: 16px; padding: 20px; background: #fff; border-radius: 12px; border: 1px solid #e2e8f0; }}
.system-info-icon {{ width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; font-size: 24px; }}
.system-info-icon svg {{ width: 100%; height: 100%; }}
.system-info-content {{ display: flex; flex-direction: column; }}
.system-info-label {{ font-size: 11px; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; }}
.system-info-value {{ font-size: 16px; font-weight: 700; color: #0f172a; }}
</style>
"""
return _render_admin_page(
request,
"Settings",
"⚙️ Settings",
body,
)
# ============================================================
# TRYOUT-SCOPED ROUTES (new hierarchy-based URLs)
# ============================================================
@router.get("/tryouts", include_in_schema=False)
async def tryouts_view(request: Request, db: AsyncSession = Depends(get_db)):
"""Tryouts overview - tree structure showing websites > tryouts with stats."""
admin = await _current_admin(request)
if not admin:
return _login_redirect()
# Get all websites with their tryouts
websites_result = await db.execute(select(Website).order_by(Website.site_name))
websites = list(websites_result.scalars().all())
# Get all tryouts with stats, grouped by website
tryouts_result = await db.execute(
select(Tryout)
.options(selectinload(Tryout.stats))
.order_by(Tryout.created_at.desc())
)
all_tryouts = list(tryouts_result.scalars().all())
# Build tree HTML
tree_html = []
for website in websites:
website_tryouts = [t for t in all_tryouts if t.website_id == website.id]
# Build tryout cards for this website
tryout_cards = []
for tryout in website_tryouts:
stats = tryout.stats
participant_count = stats.participant_count if stats else 0
avg_nm = stats.rataan if stats else None
avg_nn = stats.std if stats else None
# Get item count and calibration
items_result = await db.execute(
select(func.count(Item.id)).where(
Item.tryout_id == tryout.tryout_id,
Item.website_id == tryout.website_id,
)
)
item_count = items_result.scalar() or 0
calibrated_result = await db.execute(
select(func.count(Item.id)).where(
Item.tryout_id == tryout.tryout_id,
Item.website_id == tryout.website_id,
Item.calibrated == True,
)
)
calibrated_count = calibrated_result.scalar() or 0
calibration_pct = (
(calibrated_count / item_count * 100) if item_count > 0 else 0
)
# Calibration status indicator
if calibration_pct >= 90:
status_dot = ""
status_class = "status-ready"
elif calibration_pct >= 50:
status_dot = ""
status_class = "status-partial"
else:
status_dot = ""
status_class = "status-needs-data"
# Scoring mode badge
mode_colors = {
"ctt": ("CTT", "#dbeafe", "#1e40af"),
"irt": ("IRT", "#fce7f3", "#9d174d"),
"hybrid": ("Hybrid", "#fef3c7", "#92400e"),
}
mode_info = mode_colors.get(
tryout.scoring_mode, (tryout.scoring_mode.upper(), "#e2e8f0", "#475569")
)
tryout_cards.append(f"""
<div class="tryout-item" data-tryout-id="{tryout.id}">
<div class="tryout-header" onclick="toggleTryout(this)">
<span class="tryout-toggle">▶</span>
<span class="tryout-id">{escape(tryout.tryout_id)}</span>
<span class="tryout-name">- {escape(tryout.name or "Untitled")}</span>
<span class="calibration-indicator {status_class}" title="Calibration: {calibration_pct:.0f}%">{status_dot}</span>
</div>
<div class="tryout-expanded" style="display: none;">
<div class="tryout-stats">
<div class="stat-card">
<span class="stat-icon">👥</span>
<div class="stat-content">
<span class="stat-value">{participant_count}</span>
<span class="stat-label">Participants</span>
</div>
</div>
<div class="stat-card">
<span class="stat-icon">📊</span>
<div class="stat-content">
<span class="stat-value">{"N/A" if avg_nm is None else f"{avg_nm:.0f}"}</span>
<span class="stat-label">Avg NM</span>
</div>
</div>
<div class="stat-card">
<span class="stat-icon">📈</span>
<div class="stat-content">
<span class="stat-value">{"N/A" if avg_nn is None else f"{avg_nn:.0f}"}</span>
<span class="stat-label">Avg NN</span>
</div>
</div>
<div class="stat-card">
<span class="stat-icon">📐</span>
<div class="stat-content">
<span class="stat-value">{calibration_pct:.0f}%</span>
<span class="stat-label">Calibrated ({calibrated_count}/{item_count})</span>
</div>
</div>
</div>
<div class="tryout-actions">
<a href="/admin/tryout/{tryout.id}/questions" class="action-btn">
<span>📝</span> Questions ({item_count})
</a>
<a href="/admin/tryout/{tryout.id}/attempts" class="action-btn">
<span>👥</span> Attempts ({participant_count})
</a>
<a href="/admin/tryout/{tryout.id}/normalization" class="action-btn">
<span>📐</span> Normalization
</a>
<span class="mode-badge" style="background: {mode_info[1]}; color: {mode_info[2]};">{mode_info[0]}</span>
</div>
</div>
</div>
""")
if website_tryouts:
tree_html.append(f"""
<div class="website-group">
<div class="website-header">
<span class="website-icon">🌐</span>
<span class="website-name">{escape(website.site_name)}</span>
<span class="website-count">({len(website_tryouts)} tryouts)</span>
</div>
<div class="tryouts-list">
{"".join(tryout_cards)}
</div>
</div>
""")
body = f"""
<div class="tryouts-header">
<div>
<p class="page-description">Browse tryouts organized by website. Click to expand and see stats.</p>
</div>
<a href="/admin/import-tryout" class="import-btn">
<span>+ Import Tryout</span>
</a>
</div>
<div class="tryouts-tree">
{"".join(tree_html) if tree_html else '<div class="empty-state">No tryouts yet. <a href="/admin/import-tryout">Import a tryout</a> to get started.</div>'}
</div>
<style>
.page-description {{ color: #64748b; margin-bottom: 24px; font-size: 15px; }}
.tryouts-header {{ display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px; }}
.import-btn {{ display: inline-flex; align-items: center; gap: 8px; padding: 12px 20px; background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); color: #fff; border-radius: 10px; font-weight: 600; text-decoration: none; transition: all 0.2s; }}
.import-btn:hover {{ transform: translateY(-2px); box-shadow: 0 8px 25px rgba(59, 130, 246, 0.3); text-decoration: none; }}
.tryouts-tree {{ background: #fff; border-radius: 16px; padding: 24px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); }}
.website-group {{ margin-bottom: 24px; }}
.website-group:last-child {{ margin-bottom: 0; }}
.website-header {{ display: flex; align-items: center; gap: 10px; padding: 14px 16px; background: linear-gradient(135deg, #1e293b 0%, #334155 100%); border-radius: 10px; color: #fff; margin-bottom: 12px; }}
.website-icon {{ font-size: 20px; }}
.website-name {{ font-weight: 600; font-size: 15px; }}
.website-count {{ font-size: 13px; opacity: 0.8; margin-left: auto; }}
.tryouts-list {{ padding-left: 20px; border-left: 2px solid #e2e8f0; margin-left: 10px; }}
.tryout-item {{ margin-bottom: 8px; }}
.tryout-header {{ display: flex; align-items: center; gap: 10px; padding: 12px 16px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; cursor: pointer; transition: all 0.2s; }}
.tryout-header:hover {{ background: #e2e8f0; border-color: #cbd5e1; }}
.tryout-toggle {{ font-size: 12px; color: #64748b; transition: transform 0.2s; }}
.tryout-header.expanded .tryout-toggle {{ transform: rotate(90deg); }}
.tryout-id {{ font-weight: 700; color: #0f172a; }}
.tryout-name {{ color: #64748b; flex: 1; }}
.calibration-indicator {{ width: 20px; height: 20px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; }}
.status-ready {{ background: #dcfce7; color: #166534; }}
.status-partial {{ background: #fef3c7; color: #92400e; }}
.status-needs-data {{ background: #fee2e2; color: #991b1b; }}
.tryout-expanded {{ padding: 16px; background: #fff; border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 8px 8px; margin-top: -8px; margin-left: 20px; }}
.tryout-stats {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 16px; }}
.stat-card {{ display: flex; align-items: center; gap: 10px; padding: 12px; background: #f8fafc; border-radius: 8px; }}
.stat-icon {{ font-size: 20px; }}
.stat-content {{ display: flex; flex-direction: column; }}
.stat-value {{ font-size: 18px; font-weight: 700; color: #0f172a; line-height: 1; }}
.stat-label {{ font-size: 11px; color: #64748b; margin-top: 2px; }}
.tryout-actions {{ display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }}
.action-btn {{ display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px; background: #e2e8f0; border-radius: 6px; color: #0f172a; font-size: 13px; font-weight: 600; text-decoration: none; transition: all 0.2s; }}
.action-btn:hover {{ background: #cbd5e1; text-decoration: none; }}
.mode-badge {{ padding: 4px 10px; border-radius: 6px; font-size: 11px; font-weight: 700; margin-left: auto; }}
.empty-state {{ text-align: center; padding: 60px 20px; color: #64748b; }}
.empty-state a {{ color: #3b82f6; font-weight: 600; }}
</style>
<script>
function toggleTryout(header) {{
const item = header.closest('.tryout-item');
const expanded = item.querySelector('.tryout-expanded');
header.classList.toggle('expanded');
if (expanded.style.display === 'none') {{
expanded.style.display = 'block';
}} else {{
expanded.style.display = 'none';
}}
}}
</script>
"""
return _render_admin_page(
request,
"Tryouts",
"📋 Tryouts",
body,
)
@router.get("/tryout/{tryout_id}/questions", include_in_schema=False)
async def tryout_questions_view(
request: Request,
tryout_id: int,
db: AsyncSession = Depends(get_db),
page: int = 1,
):
"""View original questions with collapsible variant rows in a specific tryout."""
admin = await _current_admin(request)
if not admin:
return _login_redirect()
# Get the tryout
tryout_result = await db.execute(select(Tryout).where(Tryout.id == tryout_id))
tryout = tryout_result.scalar_one_or_none()
if not tryout:
raise HTTPException(status_code=404, detail="Tryout not found")
# Get only ORIGINAL questions (basis_item_id = NULL)
# Include variants relationship to show AI-generated variants
original_items_query = (
select(Item)
.options(selectinload(Item.variants))
.where(
Item.tryout_id == tryout.tryout_id,
Item.website_id == tryout.website_id,
Item.basis_item_id.is_(None), # Only original questions
)
.order_by(Item.slot.asc())
)
# Get total count of original questions
count_result = await db.execute(
select(func.count(Item.id)).where(
Item.tryout_id == tryout.tryout_id,
Item.website_id == tryout.website_id,
Item.basis_item_id.is_(None),
)
)
total_original = count_result.scalar() or 0
# Get total variant count
variant_count_result = await db.execute(
select(func.count(Item.id)).where(
Item.tryout_id == tryout.tryout_id,
Item.website_id == tryout.website_id,
Item.basis_item_id.isnot(None),
)
)
total_variants = variant_count_result.scalar() or 0
# Pagination (for original questions only)
per_page = 25
total_pages = max(1, (total_original + per_page - 1) // per_page)
page = max(1, min(page, total_pages))
offset = (page - 1) * per_page
original_items_query = original_items_query.offset(offset).limit(per_page)
result = await db.execute(original_items_query)
original_items = list(result.scalars().all())
# Build question rows with collapsible variants
question_rows = []
for item in original_items:
# Difficulty
p_value = item.ctt_p
if p_value is None:
difficulty_label = "Unknown"
difficulty_class = "difficulty-unknown"
elif p_value > 0.70:
difficulty_label = "Easy"
difficulty_class = "difficulty-easy"
elif p_value >= 0.30:
difficulty_label = "Medium"
difficulty_class = "difficulty-medium"
else:
difficulty_label = "Hard"
difficulty_class = "difficulty-hard"
stem_preview = escape(_truncate(_html_to_text(item.stem or ""), 100))
variants = item.variants or []
variant_count = len(variants)
# Toggle icon for variants
toggle_icon = (
f"""<span class="variant-toggle expanded" onclick="toggleVariants(this)">▼</span>"""
if variant_count > 0
else ""
)
question_rows.append(f"""
<tr class="question-row original-row" data-item-id="{item.id}">
<td class="question-slot">
{toggle_icon}
<span class="slot-number">{item.slot}</span>
</td>
<td>
<a href="/admin/tryout/{tryout_id}/questions/{item.id}/workspace" class="question-stem-link">{stem_preview}</a>
<div class="question-meta">
<span class="difficulty-badge {difficulty_class}">{difficulty_label}</span>
<span class="meta-sep">|</span>
<span>ID #{item.id}</span>
<span class="meta-sep">|</span>
<span>Used {item.calibration_sample_size or 0}x</span>
{f'<span class="meta-sep">|</span><span class="variant-count-badge">{variant_count} variant{"s" if variant_count != 1 else ""}</span>' if variant_count > 0 else ""}
</div>
</td>
<td>{escape(item.level or "-")}</td>
<td>
<span class="status-pill {"status-approved" if item.calibrated else "status-draft"}>
{"✅ Calibrated" if item.calibrated else "⏳ Needs Data"}
</span>
</td>
<td>
<a href="/admin/tryout/{tryout_id}/questions/{item.id}/workspace" class="button-link">Workspace</a>
</td>
</tr>
""")
# Add variant rows (collapsed by default)
if variant_count > 0:
variant_rows = []
for variant in variants:
v_p_value = variant.ctt_p
if v_p_value is None:
v_difficulty_label = "Unknown"
v_difficulty_class = "difficulty-unknown"
elif v_p_value > 0.70:
v_difficulty_label = "Easy"
v_difficulty_class = "difficulty-easy"
elif v_p_value >= 0.30:
v_difficulty_label = "Medium"
v_difficulty_class = "difficulty-medium"
else:
v_difficulty_label = "Hard"
v_difficulty_class = "difficulty-hard"
v_stem_preview = escape(
_truncate(_html_to_text(variant.stem or ""), 80)
)
v_calibrated_class = (
"status-approved" if variant.calibrated else "status-draft"
)
v_calibrated_label = (
"✅ Calibrated" if variant.calibrated else "⏳ Needs Data"
)
v_source_icon = "🤖" if variant.generated_by == "ai" else "📝"
v_source_label = "AI" if variant.generated_by == "ai" else "Manual"
variant_rows.append(f"""
<tr class="variant-row" data-parent-id="{item.id}" style="display: table-row;">
<td class="variant-slot">
<span class="variant-indent"></span>
<span class="variant-icon">{v_source_icon}</span>
<span class="variant-id-text">#{variant.id}</span>
</td>
<td class="variant-stem">
<a href="/admin/tryout/{tryout_id}/questions/{variant.id}/workspace" class="variant-stem-link">{v_stem_preview}</a>
<div class="variant-meta">
<span class="difficulty-badge {v_difficulty_class}">{v_difficulty_label}</span>
<span class="meta-sep">|</span>
<span class="source-badge">{v_source_label}</span>
{f'<span class="meta-sep">|</span><span>Model: {variant.ai_model.split("/")[-1] if variant.ai_model else "N/A"}</span>' if variant.ai_model else ""}
</div>
</td>
<td class="variant-level">{escape(variant.level or "-")}</td>
<td class="variant-status">
<span class="status-pill {v_calibrated_class}">{v_calibrated_label}</span>
</td>
<td class="variant-actions">
<a href="/admin/tryout/{tryout_id}/questions/{variant.id}/workspace" class="button-link button-small">View</a>
</td>
</tr>
""")
question_rows.extend(variant_rows)
# Pagination HTML
pagination_html = ""
if total_pages > 1:
page_links = []
for p in range(max(1, page - 2), min(total_pages + 1, page + 3)):
active_class = "active" if p == page else ""
page_links.append(
f'<a href="?page={p}" class="page-link {active_class}">{p}</a>'
)
pagination_html = f"""
<div class="pagination">
<span class="pagination-info">Showing {offset + 1}-{min(offset + per_page, total_original)} original questions ({total_variants} variants)</span>
<div class="page-links">
{" ".join(page_links)}
</div>
</div>
"""
# Summary stats
summary_html = f"""
<div class="summary-stats">
<div class="summary-stat">
<span class="summary-icon">📝</span>
<div class="summary-content">
<span class="summary-value">{total_original}</span>
<span class="summary-label">Original Questions</span>
</div>
</div>
<div class="summary-stat">
<span class="summary-icon">🤖</span>
<div class="summary-content">
<span class="summary-value">{total_variants}</span>
<span class="summary-label">AI Variants</span>
</div>
</div>
</div>
"""
table_html = f"""
<div class="table-wrap">
<table class="question-table">
<thead>
<tr>
<th style="width:60px">#</th>
<th>Question</th>
<th style="width:80px">Level</th>
<th style="width:120px">Status</th>
<th style="width:80px">Actions</th>
</tr>
</thead>
<tbody>
{
"".join(question_rows)
if question_rows
else f'<tr><td colspan="5" class="empty-state">No original questions in this tryout.</td></tr>'
}
</tbody>
</table>
</div>
"""
body = f"""
<div class="page-header">
<div>
<h2 class="page-title">{escape(tryout.name or tryout.tryout_id)}</h2>
<p class="page-description">Original questions and their AI-generated variants</p>
</div>
<div class="header-actions">
<a href="/admin/tryouts" class="back-link">← Back to Tryouts</a>
</div>
</div>
{summary_html}
{table_html}
{pagination_html}
<style>
.page-header {{ display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px; }}
.page-title {{ margin: 0; font-size: 24px; color: #0f172a; }}
.page-description {{ color: #64748b; margin: 4px 0 0; }}
.back-link {{ color: #3b82f6; font-weight: 600; }}
.summary-stats {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }}
.summary-stat {{ display: flex; align-items: center; gap: 16px; padding: 20px; background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); border-radius: 14px; color: #fff; }}
.summary-icon {{ font-size: 32px; }}
.summary-content {{ display: flex; flex-direction: column; }}
.summary-value {{ font-size: 28px; font-weight: 700; line-height: 1; }}
.summary-label {{ font-size: 13px; opacity: 0.9; margin-top: 4px; }}
.question-table {{ min-width: 800px; }}
.question-table th {{ text-align: center; }}
.question-table th:first-child {{ text-align: center; }}
.question-table td:first-child {{ text-align: center; }}
.question-row:hover {{ background: #f8fafc; }}
.question-slot {{ font-weight: 700; color: #0f172a; font-size: 14px; }}
.slot-number {{ display: inline-block; min-width: 24px; text-align: center; }}
.question-stem-link {{ font-weight: 500; color: #0f172a; text-decoration: none; }}
.question-stem-link:hover {{ color: #3b82f6; text-decoration: underline; }}
.question-meta {{ display: flex; align-items: center; gap: 8px; margin-top: 6px; font-size: 12px; color: #64748b; }}
.meta-sep {{ color: #cbd5e1; }}
.difficulty-badge {{ padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }}
.difficulty-easy {{ background: #dcfce7; color: #166534; }}
.difficulty-medium {{ background: #fef3c7; color: #92400e; }}
.difficulty-hard {{ background: #fee2e2; color: #991b1b; }}
.difficulty-unknown {{ background: #e2e8f0; color: #475569; }}
.variant-toggle {{ cursor: pointer; color: #3b82f6; margin-right: 4px; user-select: none; transition: transform 0.2s; display: inline-block; width: 16px; font-size: 12px; vertical-align: middle; }}
.variant-toggle:hover {{ color: #2563eb; }}
.variant-count-badge {{ background: #ede9fe; color: #6d28d9; padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: 600; }}
.variant-row {{ background: #fafafa; }}
.variant-row:hover {{ background: #f1f5f9; }}
.variant-slot {{ display: flex; align-items: center; gap: 4px; }}
.variant-indent {{ display: inline-block; width: 20px; }}
.variant-icon {{ font-size: 14px; line-height: 1; display: inline-block; }}
.variant-id-text {{ font-size: 11px; background: #ede9fe; color: #6d28d9; padding: 1px 5px; border-radius: 3px; }}
.variant-stem {{ padding-left: 0 !important; }}
.variant-stem-link {{ font-weight: 400; color: #475569; text-decoration: none; font-size: 13px; }}
.variant-stem-link:hover {{ color: #3b82f6; text-decoration: underline; }}
.variant-meta {{ display: flex; align-items: center; gap: 8px; margin-top: 4px; font-size: 11px; color: #64748b; }}
.source-badge {{ background: #e0f2fe; color: #0369a1; padding: 1px 5px; border-radius: 3px; font-size: 10px; font-weight: 600; }}
.button-small {{ padding: 4px 8px; font-size: 11px; }}
.pagination {{ display: flex; justify-content: space-between; align-items: center; margin-top: 20px; flex-wrap: wrap; gap: 12px; }}
.pagination-info {{ color: #64748b; font-size: 14px; }}
.page-links {{ display: flex; gap: 4px; }}
.page-link {{ display: inline-block; padding: 8px 12px; border-radius: 6px; background: #e2e8f0; color: #0f172a; text-decoration: none; font-size: 14px; }}
.page-link:hover {{ background: #cbd5e1; text-decoration: none; }}
.page-link.active {{ background: #3b82f6; color: #fff; }}
</style>
<script>
function toggleVariants(toggleEl) {{
const row = toggleEl.closest('.original-row');
const itemId = row.getAttribute('data-item-id');
const isExpanded = toggleEl.classList.contains('expanded');
// Toggle the icon
toggleEl.classList.toggle('expanded');
toggleEl.textContent = isExpanded ? '' : '';
// Toggle all variant rows with this parent ID
const variantRows = document.querySelectorAll(`.variant-row[data-parent-id="${{itemId}}"]`);
variantRows.forEach(variantRow => {{
variantRow.style.display = isExpanded ? 'none' : 'table-row';
}});
}}
// Initialize: hide all variants by default if not expanded
document.querySelectorAll('.variant-row').forEach(row => {{
const parentId = row.getAttribute('data-parent-id');
const parentRow = document.querySelector(`.original-row[data-item-id="${{parentId}}"]`);
const toggle = parentRow?.querySelector('.variant-toggle');
if (toggle && !toggle.classList.contains('expanded')) {{
row.style.display = 'none';
}}
}});
</script>
"""
return _render_admin_page(
request,
f"Questions - {tryout.name or tryout.tryout_id}",
"📝 Questions",
body,
breadcrumbs=_breadcrumbs(
request,
[
("Tryouts", "/admin/tryouts"),
(tryout.name or tryout.tryout_id, None),
("Questions", None),
],
),
)
@router.get("/tryout/{tryout_id}/attempts", include_in_schema=False)
async def tryout_attempts_view(
request: Request,
tryout_id: int,
db: AsyncSession = Depends(get_db),
status: str = "",
page: int = 1,
):
"""View student attempts for a specific tryout."""
admin = await _current_admin(request)
if not admin:
return _login_redirect()
# Get the tryout
tryout_result = await db.execute(select(Tryout).where(Tryout.id == tryout_id))
tryout = tryout_result.scalar_one_or_none()
if not tryout:
raise HTTPException(status_code=404, detail="Tryout not found")
# Get sessions for this tryout
sessions_query = (
select(Session)
.options(selectinload(Session.user))
.where(Session.tryout_id == tryout.tryout_id)
)
if status == "completed":
sessions_query = sessions_query.where(Session.is_completed == True)
elif status == "in_progress":
sessions_query = sessions_query.where(Session.is_completed == False)
sessions_query = sessions_query.order_by(Session.created_at.desc())
# Pagination
page_size = 50
offset = (page - 1) * page_size
# Get count
count_query = select(func.count(Session.id)).where(
Session.tryout_id == tryout.tryout_id
)
if status == "completed":
count_query = count_query.where(Session.is_completed == True)
elif status == "in_progress":
count_query = count_query.where(Session.is_completed == False)
total_count = await db.scalar(count_query) or 0
total_pages = max(1, (total_count + page_size - 1) // page_size)
sessions_query = sessions_query.offset(offset).limit(page_size)
result = await db.execute(sessions_query)
sessions = list(result.scalars().all())
# Summary stats
completed_count = (
await db.scalar(
select(func.count(Session.id)).where(
Session.tryout_id == tryout.tryout_id, Session.is_completed == True
)
)
or 0
)
avg_nm_result = await db.execute(
select(func.avg(Session.NM)).where(
Session.tryout_id == tryout.tryout_id,
Session.is_completed == True,
Session.NM.isnot(None),
)
)
avg_nm = avg_nm_result.scalar() or 0
avg_nn_result = await db.execute(
select(func.avg(Session.NN)).where(
Session.tryout_id == tryout.tryout_id,
Session.is_completed == True,
Session.NN.isnot(None),
)
)
avg_nn = avg_nn_result.scalar() or 0
summary_html = f"""
<div class="summary-stats">
<div class="summary-stat">
<span class="summary-icon">👥</span>
<div class="summary-content">
<span class="summary-value">{total_count}</span>
<span class="summary-label">Total Attempts</span>
</div>
</div>
<div class="summary-stat">
<span class="summary-icon">✓</span>
<div class="summary-content">
<span class="summary-value">{completed_count}</span>
<span class="summary-label">Completed</span>
</div>
</div>
<div class="summary-stat">
<span class="summary-icon">📊</span>
<div class="summary-content">
<span class="summary-value">{avg_nm:.0f}</span>
<span class="summary-label">Avg NM</span>
</div>
</div>
<div class="summary-stat">
<span class="summary-icon">📈</span>
<div class="summary-content">
<span class="summary-value">{avg_nn:.0f}</span>
<span class="summary-label">Avg NN</span>
</div>
</div>
</div>
"""
# Build sessions table
if sessions:
session_rows = []
for session in sessions:
user_name = session.user.wp_user_id if session.user else session.wp_user_id
status_badge = (
'<span class="status-badge status-completed">✓ Completed</span>'
if session.is_completed
else '<span class="status-badge status-progress">⟳ In Progress</span>'
)
nm_display = f"{session.NM:.0f}" if session.NM is not None else "N/A"
nn_display = f"{session.NN:.0f}" if session.NN is not None else "N/A"
theta_display = (
f"{session.theta:.2f}" if session.theta is not None else "N/A"
)
time_display = (
f"{(session.end_time - session.start_time).seconds // 60} min"
if session.end_time and session.start_time
else "N/A"
)
session_rows.append(f"""
<tr>
<td>{escape(user_name)}</td>
<td>{status_badge}</td>
<td>{session.total_benar}</td>
<td>{nm_display}</td>
<td>{nn_display}</td>
<td>{theta_display}</td>
<td>{time_display}</td>
<td>{escape(str(session.start_time)[:19] if session.start_time else "")}</td>
</tr>
""")
sessions_table = f"""
<table class="data-table">
<thead>
<tr>
<th>Student</th>
<th>Status</th>
<th>Correct</th>
<th>NM Score</th>
<th>NN Score</th>
<th>Theta</th>
<th>Duration</th>
<th>Started</th>
</tr>
</thead>
<tbody>
{"".join(session_rows)}
</tbody>
</table>
"""
else:
sessions_table = (
'<div class="empty-state-card">No attempts found for this tryout.</div>'
)
# Pagination
pagination_html = ""
if total_pages > 1:
page_links = []
for p in range(1, total_pages + 1):
active_class = 'class="active"' if p == page else ""
page_links.append(
f'<a href="?status={escape(status)}&page={p}" {active_class}>{p}</a>'
)
pagination_html = f'<div class="pagination">{" ".join(page_links)}</div>'
status_options = f"""
<select name="status" onchange="this.form.submit()">
<option value="" {"selected" if not status else ""}>All Status</option>
<option value="completed" {"selected" if status == "completed" else ""}>Completed</option>
<option value="in_progress" {"selected" if status == "in_progress" else ""}>In Progress</option>
</select>
"""
body = f"""
<div class="page-header">
<div>
<h2 class="page-title">{escape(tryout.name or tryout.tryout_id)}</h2>
<p class="page-description">Student attempts for this tryout</p>
</div>
<div class="header-actions">
<a href="/admin/tryouts" class="back-link">← Back to Tryouts</a>
</div>
</div>
<form method="get" class="filter-bar">
<label>Status:</label>
{status_options}
</form>
{summary_html}
<h3 class="section-title">Student Attempts</h3>
{sessions_table}
{pagination_html}
<style>
.page-header {{ display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px; }}
.page-title {{ margin: 0; font-size: 24px; color: #0f172a; }}
.page-description {{ color: #64748b; margin: 4px 0 0; }}
.back-link {{ color: #3b82f6; font-weight: 600; }}
.filter-bar {{ display: flex; align-items: center; gap: 16px; margin-bottom: 24px; padding: 16px; background: #f8fafc; border-radius: 12px; }}
.filter-bar label {{ font-weight: 600; color: #475569; }}
.filter-bar select {{ padding: 8px 12px; border: 1px solid #e2e8f0; border-radius: 8px; }}
.summary-stats {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; margin-bottom: 32px; }}
.summary-stat {{ display: flex; align-items: center; gap: 16px; padding: 20px; background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); border-radius: 14px; color: #fff; }}
.summary-icon {{ font-size: 28px; }}
.summary-content {{ display: flex; flex-direction: column; }}
.summary-value {{ font-size: 28px; font-weight: 700; line-height: 1; }}
.summary-label {{ font-size: 13px; opacity: 0.9; margin-top: 4px; }}
.section-title {{ font-size: 16px; color: #475569; margin: 32px 0 20px; font-weight: 600; text-transform: uppercase; }}
.data-table {{ width: 100%; border-collapse: collapse; background: white; border-radius: 12px; overflow: hidden; }}
.data-table th {{ background: #f1f5f9; padding: 12px 16px; text-align: left; font-weight: 600; color: #475569; }}
.data-table td {{ padding: 12px 16px; border-top: 1px solid #e2e8f0; }}
.status-badge {{ padding: 4px 10px; border-radius: 6px; font-size: 12px; font-weight: 600; }}
.status-completed {{ background: #dcfce7; color: #166534; }}
.status-progress {{ background: #fef3c7; color: #92400e; }}
.pagination {{ display: flex; justify-content: center; gap: 8px; margin-top: 24px; }}
.pagination a {{ padding: 8px 14px; background: white; border: 1px solid #e2e8f0; border-radius: 8px; text-decoration: none; color: #475569; }}
.pagination a:hover {{ background: #f1f5f9; }}
.pagination a.active {{ background: #3b82f6; color: white; border-color: #3b82f6; }}
.empty-state-card {{ text-align: center; padding: 60px 20px; background: #f8fafc; border-radius: 12px; color: #64748b; }}
</style>
"""
return _render_admin_page(
request,
f"Attempts - {tryout.name or tryout.tryout_id}",
"👥 Attempts",
body,
breadcrumbs=_breadcrumbs(
request,
[
("Tryouts", "/admin/tryouts"),
(tryout.name or tryout.tryout_id, None),
("Attempts", None),
],
),
)
@router.get("/tryout/{tryout_id}/normalization", include_in_schema=False)
async def tryout_normalization_view(
request: Request,
tryout_id: int,
db: AsyncSession = Depends(get_db),
):
"""Normalization settings for a specific tryout."""
admin = await _current_admin(request)
if not admin:
return _login_redirect()
# Get the tryout
tryout_result = await db.execute(select(Tryout).where(Tryout.id == tryout_id))
tryout = tryout_result.scalar_one_or_none()
if not tryout:
raise HTTPException(status_code=404, detail="Tryout not found")
# Get tryout stats
stats_result = await db.execute(
select(TryoutStats).where(TryoutStats.tryout_id == tryout.tryout_id)
)
stats = stats_result.scalar_one_or_none()
# Current values
current_rataan = stats.rataan if stats else 500
current_sb = stats.std if stats else 100
current_minimum = stats.minimum if stats else 0
current_maximum = stats.maximum if stats else 1000
body = f"""
<div class="page-header">
<div>
<h2 class="page-title">Normalization Settings</h2>
<p class="page-description">Configure score normalization for {escape(tryout.name or tryout.tryout_id)}</p>
</div>
<div class="header-actions">
<a href="/admin/tryouts" class="back-link">← Back to Tryouts</a>
</div>
</div>
<div class="settings-grid">
<div class="settings-card">
<h3>Current Statistics</h3>
<div class="stats-display">
<div class="stat-row">
<span class="stat-label">Participants:</span>
<span class="stat-value">{stats.participant_count if stats else 0}</span>
</div>
<div class="stat-row">
<span class="stat-label">Current Mean (NM):</span>
<span class="stat-value">{current_rataan:.2f}</span>
</div>
<div class="stat-row">
<span class="stat-label">Current Std Dev:</span>
<span class="stat-value">{current_sb:.2f}</span>
</div>
<div class="stat-row">
<span class="stat-label">Score Range:</span>
<span class="stat-value">{current_minimum:.0f} - {current_maximum:.0f}</span>
</div>
</div>
</div>
<div class="settings-card">
<h3>Normalization Formula</h3>
<div class="formula-box">
<code>NN = 500 + 100 × ((NM - Rataan) / SB)</code>
</div>
<p class="formula-description">
Where <strong>NM</strong> is the raw score, <strong>Rataan</strong> is the target mean,
and <strong>SB</strong> is the target standard deviation.
</p>
</div>
<div class="settings-card wide">
<h3>Target Parameters</h3>
<form method="post" action="/admin/api/tryout/{tryout_id}/normalization">
<div class="form-row">
<div class="form-group">
<label for="rataan">Target Mean (Rataan)</label>
<input type="number" id="rataan" name="rataan" value="{current_rataan:.0f}" step="1">
</div>
<div class="form-group">
<label for="sb">Target Std Dev (SB)</label>
<input type="number" id="sb" name="sb" value="{current_sb:.0f}" step="1">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">Save Settings</button>
<a href="/admin/api/tryout/{tryout_id}/recalculate" class="btn-secondary">Recalculate Scores</a>
</div>
</form>
</div>
</div>
<style>
.page-header {{ display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px; }}
.page-title {{ margin: 0; font-size: 24px; color: #0f172a; }}
.page-description {{ color: #64748b; margin: 4px 0 0; }}
.back-link {{ color: #3b82f6; font-weight: 600; }}
.settings-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; }}
.settings-card {{ background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 24px; }}
.settings-card.wide {{ grid-column: 1 / -1; }}
.settings-card h3 {{ margin: 0 0 16px; font-size: 16px; color: #0f172a; }}
.stats-display {{ background: #f8fafc; border-radius: 8px; padding: 16px; }}
.stat-row {{ display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #e2e8f0; }}
.stat-row:last-child {{ border-bottom: none; }}
.stat-label {{ color: #64748b; }}
.stat-value {{ font-weight: 600; color: #0f172a; }}
.formula-box {{ background: #1e293b; color: #e2e8f0; padding: 16px; border-radius: 8px; margin-bottom: 12px; }}
.formula-box code {{ font-family: monospace; font-size: 16px; }}
.formula-description {{ font-size: 14px; color: #64748b; margin: 0; }}
.form-row {{ display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin-bottom: 16px; }}
.form-group label {{ display: block; font-weight: 600; margin-bottom: 8px; color: #475569; }}
.form-group input {{ width: 100%; padding: 12px; border: 1px solid #e2e8f0; border-radius: 8px; font-size: 16px; }}
.form-actions {{ display: flex; gap: 12px; }}
.btn-primary {{ padding: 12px 24px; background: #3b82f6; color: #fff; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; }}
.btn-primary:hover {{ background: #2563eb; }}
.btn-secondary {{ padding: 12px 24px; background: #e2e8f0; color: #0f172a; border-radius: 8px; font-weight: 600; text-decoration: none; }}
.btn-secondary:hover {{ background: #cbd5e1; }}
</style>
"""
return _render_admin_page(
request,
f"Normalization - {tryout.name or tryout.tryout_id}",
"📐 Normalization",
body,
breadcrumbs=_breadcrumbs(
request,
[
("Tryouts", "/admin/tryouts"),
(tryout.name or tryout.tryout_id, None),
("Normalization", None),
],
),
)
@router.get("/import-tryout", include_in_schema=False)
async def import_tryout_view(request: Request, db: AsyncSession = Depends(get_db)):
"""Import tryout page - import tryout JSON files."""
admin = await _current_admin(request)
if not admin:
return _login_redirect()
# Get websites for selection
websites_result = await db.execute(select(Website).order_by(Website.site_name))
websites = list(websites_result.scalars().all())
website_options = "".join(
f'<option value="{site.id}">{escape(site.site_name)}</option>'
for site in websites
)
body = f"""
<div class="page-header">
<div>
<h2 class="page-title">Import Tryout</h2>
<p class="page-description">Import a complete tryout from JSON file</p>
</div>
<div class="header-actions">
<a href="/admin/tryouts" class="back-link">← Back to Tryouts</a>
</div>
</div>
<div class="import-form-container">
<form method="post" action="/admin/api/import-tryout" enctype="multipart/form-data" class="import-form">
<div class="form-group">
<label for="website_id">Target Website</label>
<select id="website_id" name="website_id" required>
<option value="">Select a website...</option>
{website_options}
</select>
</div>
<div class="form-group">
<label for="json_file">JSON File</label>
<input type="file" id="json_file" name="json_file" accept=".json" required>
<p class="form-hint">Upload a tryout JSON file</p>
</div>
<div class="form-group">
<label for="json_content">Or paste JSON content</label>
<textarea id="json_content" name="json_content" rows="10" placeholder='{{"tryout_id": "...", "name": "...", "questions": [...]}}'></textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">Import Tryout</button>
</div>
</form>
<div class="import-help">
<h3>Import Format</h3>
<p>The JSON file should contain:</p>
<ul>
<li><code>tryout_id</code> - Unique tryout identifier</li>
<li><code>name</code> - Tryout name/title</li>
<li><code>questions</code> - Array of question objects</li>
</ul>
</div>
</div>
<style>
.page-header {{ display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px; }}
.page-title {{ margin: 0; font-size: 24px; color: #0f172a; }}
.page-description {{ color: #64748b; margin: 4px 0 0; }}
.back-link {{ color: #3b82f6; font-weight: 600; }}
.import-form-container {{ display: grid; grid-template-columns: 1fr 300px; gap: 24px; }}
.import-form {{ background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 24px; }}
.form-group {{ margin-bottom: 20px; }}
.form-group label {{ display: block; font-weight: 600; margin-bottom: 8px; color: #475569; }}
.form-group select, .form-group input, .form-group textarea {{ width: 100%; padding: 12px; border: 1px solid #e2e8f0; border-radius: 8px; }}
.form-group textarea {{ font-family: monospace; font-size: 13px; }}
.form-hint {{ font-size: 13px; color: #64748b; margin-top: 6px; }}
.form-actions {{ margin-top: 24px; }}
.btn-primary {{ padding: 12px 24px; background: #3b82f6; color: #fff; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; }}
.btn-primary:hover {{ background: #2563eb; }}
.import-help {{ background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 12px; padding: 24px; }}
.import-help h3 {{ margin: 0 0 12px; font-size: 16px; color: #0f172a; }}
.import-help p {{ margin: 0 0 12px; font-size: 14px; color: #64748b; }}
.import-help ul {{ margin: 0; padding-left: 20px; }}
.import-help li {{ margin-bottom: 8px; font-size: 14px; color: #475569; }}
.import-help code {{ background: #e2e8f0; padding: 2px 6px; border-radius: 4px; font-size: 13px; }}
@media (max-width: 768px) {{ .import-form-container {{ grid-template-columns: 1fr; }} }}
</style>
"""
return _render_admin_page(
request,
"Import Tryout",
"📥 Import Tryout",
body,
breadcrumbs=_breadcrumbs(
request,
[("Tryouts", "/admin/tryouts"), ("Import", None)],
),
)
# ============================================================
# LEGACY ROUTES (backward compatibility)
# ============================================================
@router.get("/hierarchy", include_in_schema=False)
async def hierarchy_view(request: Request, db: AsyncSession = Depends(get_db)):
admin = await _current_admin(request)
if not admin:
return _login_redirect()
context = await _load_hierarchy_context(db)
body = _hierarchy_view_body(context)
return _render_admin_page(
request,
"Data Overview",
"📊 Data Overview",
body,
breadcrumbs=_breadcrumbs(
request, [("Exams", "/admin/exams"), ("Data Overview", None)]
),
)
@router.get("/websites", include_in_schema=False)
async def websites_view(request: Request, db: AsyncSession = Depends(get_db)):
admin = await _current_admin(request)
if not admin:
return _login_redirect()
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(request, "Websites", "Websites", body)
@router.post("/websites", include_in_schema=False)
async def websites_submit(
request: Request,
db: AsyncSession = Depends(get_db),
site_name: str = Form(...),
site_url: str = Form(...),
):
admin = await _current_admin(request)
if not admin:
return _login_redirect()
normalized_name = site_name.strip()
normalized_url = site_url.strip().rstrip("/")
if not normalized_name:
result = await db.execute(select(Website).order_by(Website.id.asc()))
websites = list(result.scalars().all())
body = _websites_form_body(
websites,
error="Website name is required.",
site_name=site_name,
site_url=site_url,
)
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()))
websites = list(result.scalars().all())
body = _websites_form_body(
websites,
error="Website URL must start with http:// or https://.",
site_name=site_name,
site_url=site_url,
)
return _render_admin_page(request, "Websites", "Websites", body)
website = Website(site_name=normalized_name, site_url=normalized_url)
db.add(website)
try:
await db.commit()
except IntegrityError:
await db.rollback()
result = await db.execute(select(Website).order_by(Website.id.asc()))
websites = list(result.scalars().all())
body = _websites_form_body(
websites,
error="Website URL already exists.",
site_name=site_name,
site_url=site_url,
)
return _render_admin_page(request, "Websites", "Websites", body)
result = await db.execute(select(Website).order_by(Website.id.asc()))
websites = list(result.scalars().all())
body = _websites_form_body(
websites,
success=f"Website added successfully with ID {website.id}.",
)
return _render_admin_page(request, "Websites", "Websites", body)
@router.get("/websites/{website_id}/edit", include_in_schema=False)
async def website_edit_view(
website_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
):
admin = await _current_admin(request)
if not admin:
return _login_redirect()
website = await db.get(Website, website_id)
if website is None:
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(request, "Websites", "Websites", body)
body = _website_edit_form_body(website)
return _render_admin_page(request, "Edit Website", "Edit Website", body)
@router.post("/websites/{website_id}/edit", include_in_schema=False)
async def website_edit_submit(
website_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
site_name: str = Form(...),
site_url: str = Form(...),
):
admin = await _current_admin(request)
if not admin:
return _login_redirect()
website = await db.get(Website, website_id)
if website is None:
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(request, "Websites", "Websites", body)
normalized_name = site_name.strip()
normalized_url = site_url.strip().rstrip("/")
if not normalized_name:
body = _website_edit_form_body(
website,
error="Website name is required.",
site_name=site_name,
site_url=site_url,
)
return _render_admin_page(request, "Edit Website", "Edit Website", body)
if not normalized_url.startswith(("http://", "https://")):
body = _website_edit_form_body(
website,
error="Website URL must start with http:// or https://.",
site_name=site_name,
site_url=site_url,
)
return _render_admin_page(request, "Edit Website", "Edit Website", body)
website.site_name = normalized_name
website.site_url = normalized_url
try:
await db.commit()
except IntegrityError:
await db.rollback()
body = _website_edit_form_body(
website,
error="Website URL already exists.",
site_name=site_name,
site_url=site_url,
)
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(request, "Edit Website", "Edit Website", body)
@router.post("/websites/{website_id}/delete", include_in_schema=False)
async def website_delete_submit(
website_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
):
admin = await _current_admin(request)
if not admin:
return _login_redirect()
website = await db.get(Website, website_id)
if website is None:
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(request, "Websites", "Websites", body)
deleted_label = f"{website.site_name} ({website.site_url})"
await db.delete(website)
await db.commit()
result = await db.execute(select(Website).order_by(Website.id.asc()))
websites = list(result.scalars().all())
body = _websites_form_body(
websites,
success=f"Website deleted successfully: {deleted_label}",
)
return _render_admin_page(request, "Websites", "Websites", body)
@router.get("/tryout-import", include_in_schema=False)
async def tryout_import_view(request: Request, db: AsyncSession = Depends(get_db)):
admin = await _current_admin(request)
if not admin:
return _login_redirect()
websites = await _load_websites(db)
snapshots = await _recent_snapshots(db)
body = _tryout_import_form_body(websites, snapshots)
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
@router.post("/tryout-import/preview", include_in_schema=False)
async def tryout_import_preview(
request: Request,
db: AsyncSession = Depends(get_db),
website_id: int = Form(...),
file: UploadFile = File(...),
):
admin = await _current_admin(request)
if not admin:
return _login_redirect()
websites = await _load_websites(db)
snapshots = await _recent_snapshots(db)
if not file.filename or not file.filename.lower().endswith(".json"):
body = _tryout_import_form_body(
websites,
snapshots,
error="File must be .json format.",
selected_website_id=website_id,
)
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
try:
payload_bytes = await file.read()
payload_text = payload_bytes.decode("utf-8")
payload = json.loads(payload_text)
except UnicodeDecodeError:
body = _tryout_import_form_body(
websites,
snapshots,
error="File must be UTF-8 encoded JSON.",
selected_website_id=website_id,
)
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
except json.JSONDecodeError as exc:
body = _tryout_import_form_body(
websites,
snapshots,
error=f"Invalid JSON file: {exc}",
selected_website_id=website_id,
)
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
try:
preview = await preview_tryout_json_import(payload, website_id, db)
except TryoutImportError as exc:
body = _tryout_import_form_body(
websites,
snapshots,
error=str(exc),
selected_website_id=website_id,
)
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
preview_token = uuid.uuid4().hex
await _admin_redis.set(
f"{IMPORT_PREVIEW_PREFIX}{preview_token}",
payload_text,
ex=IMPORT_PREVIEW_TTL_SECONDS,
)
body = _tryout_import_form_body(
websites,
snapshots,
selected_website_id=website_id,
preview=preview,
preview_token=preview_token,
upload_filename=file.filename or "",
)
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
@router.post("/tryout-import", include_in_schema=False)
async def tryout_import_submit(
request: Request,
db: AsyncSession = Depends(get_db),
website_id: int = Form(...),
preview_token: str = Form(...),
):
admin = await _current_admin(request)
if not admin:
return _login_redirect()
websites = await _load_websites(db)
snapshots = await _recent_snapshots(db)
payload_text = await _admin_redis.get(f"{IMPORT_PREVIEW_PREFIX}{preview_token}")
if not payload_text:
body = _tryout_import_form_body(
websites,
snapshots,
error="Preview token expired. Upload the JSON again and preview before importing.",
selected_website_id=website_id,
)
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
try:
payload = json.loads(payload_text)
result = await import_tryout_json_snapshot(payload, website_id, db)
await db.commit()
except TryoutImportError as exc:
await db.rollback()
body = _tryout_import_form_body(
websites,
snapshots,
error=str(exc),
selected_website_id=website_id,
)
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
except Exception:
await db.rollback()
raise
finally:
await _admin_redis.delete(f"{IMPORT_PREVIEW_PREFIX}{preview_token}")
updated_snapshots = await _recent_snapshots(db)
imported_tryouts = result.get("imported_tryouts") or []
imported_count = sum((row.get("question_count") or 0) for row in imported_tryouts)
body = _tryout_import_form_body(
websites,
updated_snapshots,
success=(
f"Imported {len(imported_tryouts)} tryout snapshot(s) and archived {imported_count} source question reference row(s)."
),
selected_website_id=website_id,
)
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
@router.get("/snapshot-questions", include_in_schema=False)
async def snapshot_questions_view(
request: Request,
snapshot_id: int,
db: AsyncSession = Depends(get_db),
):
admin = await _current_admin(request)
if not admin:
return _login_redirect()
snapshot = await db.get(TryoutImportSnapshot, snapshot_id)
if snapshot is None:
websites = await _load_websites(db)
snapshots = await _recent_snapshots(db)
body = _tryout_import_form_body(
websites,
snapshots,
error=f"Snapshot not found: {snapshot_id}",
)
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(request, "Snapshot Questions", "Snapshot Questions", body)
@router.post("/snapshot-questions/promote-bulk", include_in_schema=False)
async def snapshot_question_promote_bulk(
request: Request,
snapshot_id: int = Form(...),
snapshot_question_ids: list[int] | None = Form(None),
db: AsyncSession = Depends(get_db),
):
admin = await _current_admin(request)
if not admin:
return _login_redirect()
snapshot = await db.get(TryoutImportSnapshot, snapshot_id)
if snapshot is None:
websites = await _load_websites(db)
snapshots = await _recent_snapshots(db)
body = _tryout_import_form_body(
websites,
snapshots,
error=f"Snapshot not found: {snapshot_id}",
)
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
)
body = _snapshot_questions_body(
snapshot,
questions,
promoted_items_by_slot,
error="Select at least one snapshot question to promote.",
)
return _render_admin_page(
request, "Snapshot Questions", "Snapshot Questions", body
)
question_result = await db.execute(
select(TryoutSnapshotQuestion).where(
TryoutSnapshotQuestion.id.in_(snapshot_question_ids)
)
)
selected_questions = list(question_result.scalars().all())
created_items: list[Item] = []
existing_items: list[Item] = []
missing_option_count = 0
mismatch_count = 0
for question in selected_questions:
item, status = await _promote_snapshot_question_to_item(snapshot, question, db)
if status == "created" and item is not None:
created_items.append(item)
elif status == "existing" and item is not None:
existing_items.append(item)
elif status == "missing_options":
missing_option_count += 1
elif status == "mismatch":
mismatch_count += 1
await db.commit()
questions, promoted_items_by_slot, _ = await _load_snapshot_question_context(
snapshot, db
)
success_parts = []
if created_items:
success_parts.append(f"created {len(created_items)} item(s)")
if existing_items:
success_parts.append(f"reused {len(existing_items)} existing item(s)")
if missing_option_count:
success_parts.append(
f"skipped {missing_option_count} question(s) with missing option text"
)
if mismatch_count:
success_parts.append(f"skipped {mismatch_count} mismatched question(s)")
success_message = "Bulk promote finished: " + ", ".join(success_parts) + "."
if created_items:
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(request, "Snapshot Questions", "Snapshot Questions", body)
@router.get("/calibration-status", include_in_schema=False)
async def calibration_status_view(request: Request, db: AsyncSession = Depends(get_db)):
admin = await _current_admin(request)
if not admin:
return _login_redirect()
result = await db.execute(
select(Tryout.tryout_id, Tryout.name, Tryout.website_id).order_by(Tryout.id)
)
tryouts = result.all()
rows = []
for tryout_id, name, website_id in tryouts:
status = await get_calibration_status(tryout_id, website_id, db)
rows.append(
[
tryout_id,
name,
status["total_items"],
status["calibrated_items"],
f"{status['calibration_percentage']:.2f}%",
"Yes" if status["ready_for_irt"] else "No",
]
)
body = _table(
[
"Tryout ID",
"Name",
"Total Items",
"Calibrated",
"Calibration %",
"Ready for IRT",
],
rows,
)
return _render_admin_page(request, "Calibration Status", "Calibration Status", body)
@router.get("/item-statistics", include_in_schema=False)
async def item_statistics_view(request: Request, db: AsyncSession = Depends(get_db)):
admin = await _current_admin(request)
if not admin:
return _login_redirect()
result = await db.execute(select(Item.level).distinct())
levels = result.scalars().all()
rows = []
for level in levels:
item_result = await db.execute(
select(Item).where(Item.level == level).order_by(Item.slot).limit(10)
)
items = item_result.scalars().all()
total_responses = sum(item.calibration_sample_size or 0 for item in items)
calibrated_count = sum(1 for item in items if item.calibrated)
calibration_percentage = (calibrated_count / len(items) * 100) if items else 0
avg_correctness = (
sum(item.ctt_p or 0 for item in items) / len(items) if items else 0
)
rows.append(
[
level,
len(items),
calibrated_count,
f"{calibration_percentage:.2f}%",
total_responses,
f"{avg_correctness:.4f}",
]
)
body = _table(
[
"Level",
"Total Items",
"Calibrated",
"Calibration %",
"Responses",
"Avg Correctness",
],
rows,
)
return _render_admin_page(request, "Item Statistics", "Item Statistics", body)
@router.get("/session-overview", include_in_schema=False)
async def session_overview_view(request: Request, db: AsyncSession = Depends(get_db)):
admin = await _current_admin(request)
if not admin:
return _login_redirect()
result = await db.execute(
select(Session).order_by(Session.created_at.desc()).limit(50)
)
sessions = result.scalars().all()
rows = [
[
session.session_id,
session.wp_user_id,
session.tryout_id,
"Yes" if session.is_completed else "No",
session.scoring_mode_used,
session.total_benar,
session.NM,
session.NN,
session.theta,
]
for session in sessions
]
body = _table(
[
"Session ID",
"WP User",
"Tryout",
"Completed",
"Mode",
"Benar",
"NM",
"NN",
"Theta",
],
rows,
)
return _render_admin_page(request, "Session Overview", "Session Overview", body)
@router.get("/basis-items", include_in_schema=False)
async def basis_items_view(request: Request, db: AsyncSession = Depends(get_db)):
admin = await _current_admin(request)
if not admin:
return _login_redirect()
result = await db.execute(
select(Item)
.where(Item.level == "sedang", Item.generated_by != "ai")
.order_by(Item.updated_at.desc(), Item.id.desc())
.limit(200)
)
basis_items = list(result.scalars().all())
body = _basis_items_list_body(basis_items)
return _render_admin_page(request, "Basis Items", "Basis Items", body)
@router.get("/basis-items/{basis_item_id}", include_in_schema=False)
async def basis_item_workspace_view(
basis_item_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
):
admin = await _current_admin(request)
if not admin:
return _login_redirect()
status_filter = (request.query_params.get("status") or "").strip()
level_filter = (request.query_params.get("level") or "").strip()
run_id_filter = (request.query_params.get("run_id") or "").strip()
min_frequency_filter = (request.query_params.get("min_frequency") or "").strip()
filters = {
"status": status_filter,
"level": level_filter,
"run_id": run_id_filter,
"min_frequency": min_frequency_filter,
}
basis_item = await db.get(Item, basis_item_id)
if (
basis_item is None
or basis_item.generated_by == "ai"
or basis_item.level != "sedang"
):
result = await db.execute(
select(Item)
.where(Item.level == "sedang", Item.generated_by != "ai")
.order_by(Item.updated_at.desc(), Item.id.desc())
.limit(200)
)
body = _basis_items_list_body(list(result.scalars().all()))
return _render_admin_page(request, "Basis Items", "Basis Items", body)
run_result = await db.execute(
select(AIGenerationRun)
.where(AIGenerationRun.basis_item_id == basis_item.id)
.order_by(AIGenerationRun.id.desc())
.limit(50)
)
runs = list(run_result.scalars().all())
variant_result = await db.execute(
select(Item)
.where(Item.generated_by == "ai", Item.basis_item_id == basis_item.id)
.order_by(Item.created_at.desc(), Item.id.desc())
.limit(300)
)
variants_all = list(variant_result.scalars().all())
variants = variants_all
if status_filter:
variants = [item for item in variants if item.variant_status == status_filter]
if level_filter in {"mudah", "sulit"}:
variants = [item for item in variants if item.level == level_filter]
if run_id_filter.isdigit():
rid = int(run_id_filter)
variants = [item for item in variants if item.generation_run_id == rid]
usage_metrics, family_stats = await _family_usage_stats(db, basis_item, variants)
if min_frequency_filter:
try:
min_freq = float(min_frequency_filter)
variants = [
item
for item in variants
if usage_metrics.get(item.id, {}).get("frequency", 0.0) >= min_freq
]
except ValueError:
pass
body = _basis_item_workspace_body(
basis_item,
runs,
variants,
usage_metrics,
family_stats,
filters,
)
return _render_admin_page(
request,
f"Basis Item #{basis_item.id}",
f"Basis Item Workspace #{basis_item.id}",
body,
)
@router.post("/basis-items/{basis_item_id}/generate", include_in_schema=False)
async def basis_item_generate_submit(
basis_item_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
target_level: str = Form(...),
ai_model: str = Form(""),
generation_count: int = Form(1),
operator_notes: str = Form(""),
include_note_for_admin: str | None = Form(None),
include_note_in_prompt: str | None = Form(None),
):
admin = await _current_admin(request)
if not admin:
return _login_redirect()
filters = {"status": "", "level": "", "run_id": "", "min_frequency": ""}
basis_item = await db.get(Item, basis_item_id)
if (
basis_item is None
or basis_item.generated_by == "ai"
or basis_item.level != "sedang"
):
return RedirectResponse(
url="/admin/basis-items", status_code=HTTP_303_SEE_OTHER
)
# Llama-only policy for production quality consistency.
ai_model = settings.OPENROUTER_MODEL_LLAMA
note_for_admin = include_note_for_admin == "on"
note_in_prompt = include_note_in_prompt == "on"
if not settings.OPENROUTER_API_KEY:
run_result = await db.execute(
select(AIGenerationRun)
.where(AIGenerationRun.basis_item_id == basis_item.id)
.order_by(AIGenerationRun.id.desc())
.limit(50)
)
variant_result = await db.execute(
select(Item)
.where(Item.generated_by == "ai", Item.basis_item_id == basis_item.id)
.order_by(Item.created_at.desc(), Item.id.desc())
.limit(300)
)
runs = list(run_result.scalars().all())
variants = list(variant_result.scalars().all())
usage_metrics, family_stats = await _family_usage_stats(
db, basis_item, variants
)
body = _basis_item_workspace_body(
basis_item,
runs,
variants,
usage_metrics,
family_stats,
filters,
error="OPENROUTER_API_KEY is not configured.",
target_level=target_level,
ai_model=ai_model,
generation_count=str(generation_count),
operator_notes=operator_notes,
include_note_for_admin=note_for_admin,
include_note_in_prompt=note_in_prompt,
)
return _render_admin_page(
request,
f"Basis Item #{basis_item.id}",
f"Basis Item Workspace #{basis_item.id}",
body,
)
if target_level not in {"mudah", "sulit"}:
return RedirectResponse(
url=f"/admin/basis-items/{basis_item.id}", status_code=HTTP_303_SEE_OTHER
)
if generation_count < 1 or generation_count > 50:
return RedirectResponse(
url=f"/admin/basis-items/{basis_item.id}", status_code=HTTP_303_SEE_OTHER
)
run_id = await create_generation_run(
basis_item_id=basis_item.id,
source_snapshot_question_id=basis_item.source_snapshot_question_id,
target_level=target_level,
requested_count=generation_count,
model=ai_model,
created_by=admin.username,
operator_notes=(operator_notes.strip() or None) if note_for_admin else None,
db=db,
)
generated = await generate_questions_batch(
basis_item=basis_item,
target_level=target_level,
ai_model=ai_model,
count=generation_count,
operator_notes=operator_notes if note_in_prompt else None,
)
from app.schemas.ai import GeneratedQuestion
saved = 0
for generated_question in generated:
item_id = await save_ai_question(
generated_data=GeneratedQuestion(
stem=generated_question.stem,
options=generated_question.options,
correct=generated_question.correct,
explanation=generated_question.explanation or None,
),
tryout_id=basis_item.tryout_id,
website_id=basis_item.website_id,
basis_item_id=basis_item.id,
slot=basis_item.slot,
level=target_level,
ai_model=ai_model,
generation_run_id=run_id,
source_snapshot_question_id=basis_item.source_snapshot_question_id,
variant_status="draft",
db=db,
)
if item_id:
saved += 1
await db.commit()
run_result = await db.execute(
select(AIGenerationRun)
.where(AIGenerationRun.basis_item_id == basis_item.id)
.order_by(AIGenerationRun.id.desc())
.limit(50)
)
variant_result = await db.execute(
select(Item)
.where(Item.generated_by == "ai", Item.basis_item_id == basis_item.id)
.order_by(Item.created_at.desc(), Item.id.desc())
.limit(300)
)
runs = list(run_result.scalars().all())
variants = list(variant_result.scalars().all())
usage_metrics, family_stats = await _family_usage_stats(db, basis_item, variants)
status_message = (
f"Run #{run_id} failed to produce savable variants. "
f"Requested={generation_count}, Generated={len(generated)}, Saved={saved}. "
"Check model output/credentials and server logs."
if saved == 0
else f"Run #{run_id} finished. Requested={generation_count}, Generated={len(generated)}, Saved={saved}."
)
body = _basis_item_workspace_body(
basis_item,
runs,
variants,
usage_metrics,
family_stats,
filters,
error=status_message if saved == 0 else None,
success=status_message if saved > 0 else None,
target_level=target_level,
ai_model=ai_model,
generation_count=str(generation_count),
include_note_for_admin=note_for_admin,
include_note_in_prompt=note_in_prompt,
)
return _render_admin_page(
request,
f"Basis Item #{basis_item.id}",
f"Basis Item Workspace #{basis_item.id}",
body,
)
@router.post("/basis-items/{basis_item_id}/review-bulk", include_in_schema=False)
async def basis_item_review_bulk(
basis_item_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
item_ids: list[int] = Form([]),
action: str = Form(...),
):
filters = {"status": "", "level": "", "run_id": "", "min_frequency": ""}
admin = await _current_admin(request)
if not admin:
return _login_redirect()
basis_item = await db.get(Item, basis_item_id)
if basis_item is None:
return RedirectResponse(
url="/admin/basis-items", status_code=HTTP_303_SEE_OTHER
)
valid_actions = {"approved", "rejected", "archived", "stale", "active"}
if action in valid_actions and item_ids:
result = await db.execute(
select(Item).where(
Item.id.in_(item_ids),
Item.generated_by == "ai",
Item.basis_item_id == basis_item.id,
)
)
items = list(result.scalars().all())
reviewed_at = datetime.now(timezone.utc)
for item in items:
item.variant_status = action
item.reviewed_by = admin.username
item.reviewed_at = reviewed_at
await db.commit()
run_result = await db.execute(
select(AIGenerationRun)
.where(AIGenerationRun.basis_item_id == basis_item.id)
.order_by(AIGenerationRun.id.desc())
.limit(50)
)
variant_result = await db.execute(
select(Item)
.where(Item.generated_by == "ai", Item.basis_item_id == basis_item.id)
.order_by(Item.created_at.desc(), Item.id.desc())
.limit(300)
)
runs = list(run_result.scalars().all())
variants = list(variant_result.scalars().all())
usage_metrics, family_stats = await _family_usage_stats(db, basis_item, variants)
body = _basis_item_workspace_body(
basis_item,
runs,
variants,
usage_metrics,
family_stats,
filters,
success=f"Applied status '{action}' to selected variants.",
)
return _render_admin_page(
request,
f"Basis Item #{basis_item.id}",
f"Basis Item Workspace #{basis_item.id}",
body,
)
AI_PLAYGROUND_TABS = (
("generate", "Generate"),
("review", "Review Queue"),
("runs", "Batches"),
)
AI_VARIANT_STATUSES = ("draft", "approved", "active", "rejected", "archived", "stale")
AI_VARIANT_LEVELS = ("mudah", "sulit")
def _selected_option(value: str, selected_value: str) -> str:
return "selected" if value == selected_value else ""
def _ai_tab_nav(item_id: int, active_tab: str) -> str:
links = []
for tab, label in AI_PLAYGROUND_TABS:
active_class = "active" if tab == active_tab else ""
aria = ' aria-current="page"' if tab == active_tab else ""
links.append(
f'<a class="{active_class}" href="/admin/questions/{item_id}/generate?tab={tab}"{aria}>{escape(label)}</a>'
)
return f'<nav class="tabs" aria-label="Variant Generator sections">{"".join(links)}</nav>'
def _status_pill(status: str | None) -> str:
value = status or "unknown"
css_value = re.sub(r"[^a-z0-9_-]+", "-", value.lower())
return (
f'<span class="status-pill status-{escape(css_value)}">{escape(value)}</span>'
)
def _ai_status_strip(
key_configured: bool,
stats: dict[str, Any],
generation_runs: list[AIGenerationRun],
generation_summary: dict[str, Any] | None = None,
) -> str:
latest_run = "-"
latest_saved = "-"
if generation_summary:
latest_run = str(generation_summary.get("run_id", "-"))
latest_saved = str(len(generation_summary.get("saved_item_ids") or []))
elif generation_runs:
latest_run = str(generation_runs[0].id)
return f"""
<div class="compact-strip">
<div class="compact-stat"><span>OpenRouter</span><strong>{"Yes" if key_configured else "No"}</strong></div>
<div class="compact-stat"><span>AI Items</span><strong>{stats.get("total_ai_items", 0)}</strong></div>
<div class="compact-stat"><span>Latest Batch</span><strong>{escape(latest_run)}</strong></div>
<div class="compact-stat"><span>Saved</span><strong>{escape(latest_saved)}</strong></div>
</div>
"""
def _ai_generation_summary(generation_summary: dict[str, Any] | None) -> str:
if not generation_summary:
return ""
saved_item_ids = generation_summary.get("saved_item_ids") or []
return f"""
<div class="compact-strip">
<div class="compact-stat"><span>Batch ID</span><strong>{generation_summary.get("run_id", "-")}</strong></div>
<div class="compact-stat"><span>Requested</span><strong>{generation_summary.get("requested_count", 0)}</strong></div>
<div class="compact-stat"><span>Generated</span><strong>{generation_summary.get("generated_count", 0)}</strong></div>
<div class="compact-stat"><span>Saved</span><strong>{len(saved_item_ids)}</strong></div>
</div>
"""
def _ai_generate_tab(
item: Item,
generation_summary: dict[str, Any] | None,
target_level: str,
ai_model: str,
generation_count: str,
operator_notes: str,
include_note_for_admin: bool,
include_note_in_prompt: bool,
) -> str:
full_stem = escape(_html_to_text(item.stem))
basis_selection_html = f"""
<div class="question-block" style="background-color: #f8fafc; border: 1px solid #e2e8f0; padding: 16px; margin-bottom: 24px; border-radius: 4px;">
<h3 style="margin: 0 0 8px 0; color: #0f172a;">Basis Item Context</h3>
<p class="muted" style="margin: 0 0 8px 0;"><strong>Tryout:</strong> {escape(str(item.tryout_id))} | <strong>Slot:</strong> {item.slot} | <strong>ID:</strong> #{item.id}</p>
<p style="margin: 0; font-style: italic; color: #475569;">"{full_stem}"</p>
<input type="hidden" name="basis_item_id" value="{item.id}">
</div>
"""
return f"""
<section class="tab-panel">
{_ai_generation_summary(generation_summary)}
<form method="post" action="/admin/questions/{item.id}/generate?tab=generate" autocomplete="off">
{basis_selection_html}
<div id="generation-options" style="display: block;">
<div class="field-grid">
<div>
<label for="target_level">Target Level</label>
<select id="target_level" name="target_level">
<option value="mudah" {_selected_option("mudah", target_level)}>Easier</option>
<option value="sulit" {_selected_option("sulit", target_level)}>Harder</option>
</select>
</div>
<div>
<label for="generation_count">Generate Count</label>
<input id="generation_count" name="generation_count" type="number" min="1" max="50" value="{escape(generation_count)}">
</div>
<div class="wide">
<label for="ai_model">Model</label>
<input id="ai_model" name="ai_model" type="text" value="{escape(ai_model or settings.OPENROUTER_MODEL_LLAMA)}" readonly style="background:#f8fafc;">
</div>
<div class="wide">
<label for="operator_notes">Optional Notes</label>
<textarea id="operator_notes" name="operator_notes" rows="3" placeholder="Optional generation note for this batch">{escape(operator_notes)}</textarea>
</div>
</div>
<label class="row"><input type="checkbox" name="include_note_for_admin" {"checked" if include_note_for_admin else ""}> Save note for admin team</label>
<label class="row"><input type="checkbox" name="include_note_in_prompt" {"checked" if include_note_in_prompt else ""}> Include note in AI prompt payload</label>
<div class="actions">
<button type="submit">Generate Batch</button>
</div>
</div>
</form>
</section>
"""
def _ai_runs_tab(
item: Item,
generation_runs: list[AIGenerationRun],
generation_summary: dict[str, Any] | None,
) -> str:
rows = []
for run in generation_runs:
rows.append(
"<tr>"
f"<td>{run.id}</td>"
f"<td>{run.basis_item_id}</td>"
f"<td>{escape(run.target_level)}</td>"
f"<td>{run.requested_count}</td>"
f"<td>{escape(_truncate(run.model, 54))}</td>"
f"<td>{escape(run.created_by)}</td>"
f"<td>{escape(str(run.created_at))}</td>"
f'<td><a class="secondary-link" href="/admin/questions/{item.id}/generate?tab=review&run_id={run.id}">Review</a></td>'
"</tr>"
)
table = (
'<div class="table-wrap"><table><thead><tr><th>Batch ID</th><th>Basis Item</th><th>Target</th><th>Requested</th><th>Model</th><th>Created By</th><th>Created At</th><th>Action</th></tr></thead><tbody>'
+ (
"".join(rows)
if rows
else '<tr><td colspan="8">No generation batches yet.</td></tr>'
)
+ "</tbody></table></div>"
)
return f"""
<section class="tab-panel">
{_ai_generation_summary(generation_summary)}
{table}
</section>
"""
def _ai_review_tab(
item: Item,
generated_variants: list[Item],
status_filter: str,
level_filter: str,
run_id_filter: str,
) -> str:
status_options = ['<option value="">All statuses</option>']
for status in AI_VARIANT_STATUSES:
status_options.append(
f'<option value="{status}" {_selected_option(status, status_filter)}>{status}</option>'
)
level_options = ['<option value="">All levels</option>']
for level in AI_VARIANT_LEVELS:
level_options.append(
f'<option value="{level}" {_selected_option(level, level_filter)}>{level}</option>'
)
variant_rows = []
for item in generated_variants:
stem_preview = _truncate(_html_to_text(item.stem), 120)
variant_rows.append(
"<tr>"
f'<td><input type="checkbox" name="item_ids" value="{item.id}"></td>'
f"<td>{item.id}</td>"
f"<td>{item.generation_run_id or '-'}</td>"
f"<td>{item.basis_item_id or '-'}</td>"
f"<td>{escape(item.level)}</td>"
f"<td>{_status_pill(item.variant_status)}</td>"
f"<td>{escape(_truncate(item.ai_model or '-', 42))}</td>"
f"<td>{escape(stem_preview)}</td>"
f"<td>{escape(str(item.created_at))}</td>"
f'<td><a class="secondary-link" href="/admin/questions/{item.id}/generate/variants/{item.id}">View</a></td>'
"</tr>"
)
variant_table_rows = (
"".join(variant_rows)
if variant_rows
else '<tr><td colspan="10">No AI-generated variants match this view.</td></tr>'
)
return f"""
<section class="tab-panel">
<form class="toolbar" method="get" action="/admin/questions/{item.id}/generate">
<input type="hidden" name="tab" value="review">
<label>Status
<select name="status">{"".join(status_options)}</select>
</label>
<label>Level
<select name="level">{"".join(level_options)}</select>
</label>
<label>Batch ID
<input name="run_id" type="number" min="1" value="{escape(run_id_filter)}">
</label>
<button type="submit">Filter</button>
<a class="secondary-link" href="/admin/questions/{item.id}/generate?tab=review">Clear</a>
</form>
<form method="post" action="/admin/questions/{item.id}/generate/review-bulk?tab=review">
<div class="actions" style="margin:16px 0">
<select name="action" style="max-width:260px">
<option value="approved">Approve selected</option>
<option value="rejected">Reject selected</option>
<option value="archived">Archive selected</option>
<option value="stale">Mark stale</option>
<option value="active">Activate selected</option>
</select>
<button type="submit">Apply</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr><th><input type="checkbox" onclick="document.querySelectorAll('input[name=&quot;item_ids&quot;]').forEach(el => el.checked = this.checked)"></th><th>Item ID</th><th>Batch ID</th><th>Basis</th><th>Level</th><th>Status</th><th>Model</th><th>Stem</th><th>Created At</th><th>Action</th></tr>
</thead>
<tbody>
{variant_table_rows}
</tbody>
</table>
</div>
</form>
</section>
"""
def _ai_form_body(
key_configured: bool,
stats: dict[str, Any],
item: Item,
error: str | None = None,
success: str | None = None,
generation_summary: dict[str, Any] | None = None,
generation_runs: list[AIGenerationRun] | None = None,
generated_variants: list[Item] | None = None,
target_level: str = "mudah",
ai_model: str = settings.OPENROUTER_MODEL_LLAMA,
generation_count: str = "1",
operator_notes: str = "",
include_note_for_admin: bool = True,
include_note_in_prompt: bool = False,
active_tab: str = "generate",
variant_status_filter: str = "",
variant_level_filter: str = "",
variant_run_id_filter: str = "",
) -> str:
error_html = f'<div class="error">{escape(error)}</div>' if error else ""
success_html = f'<div class="success">{escape(success)}</div>' if success else ""
generation_runs = generation_runs or []
generated_variants = generated_variants or []
if active_tab not in {tab for tab, _ in AI_PLAYGROUND_TABS}:
active_tab = "generate"
tab_html = {
"generate": _ai_generate_tab(
item,
generation_summary,
target_level,
ai_model,
generation_count,
operator_notes,
include_note_for_admin,
include_note_in_prompt,
),
"review": _ai_review_tab(
item,
generated_variants,
variant_status_filter,
variant_level_filter,
variant_run_id_filter,
),
"runs": _ai_runs_tab(item, generation_runs, generation_summary),
}[active_tab]
return f"""
{_ai_status_strip(key_configured, stats, generation_runs, generation_summary)}
{success_html}
{error_html}
{_ai_tab_nav(item.id, active_tab)}
{tab_html}
"""
def _options_table(options: Any, correct_answer: str | None) -> str:
normalized_correct = str(correct_answer or "").strip().upper()
rows = []
if isinstance(options, dict):
options_by_key = {
str(key).strip().upper(): value for key, value in options.items()
}
option_keys = [key for key in ("A", "B", "C", "D") if key in options_by_key]
option_keys.extend(
sorted(key for key in options_by_key.keys() if key not in option_keys)
)
for key in option_keys:
value = options_by_key.get(key)
row_class = (
' class="correct-option"'
if str(key).upper() == normalized_correct
else ""
)
rows.append(
f"<tr{row_class}>"
f'<td class="option-key">{escape(str(key).upper())}</td>'
f"<td>{escape(_html_to_text(str(value)))}</td>"
"</tr>"
)
else:
rows.append(
f'<tr><td colspan="2">{escape(_html_to_text(str(options or "")))}</td></tr>'
)
return (
'<div class="table-wrap"><table><thead><tr><th>Option</th><th>Text</th></tr></thead><tbody>'
+ (
"".join(rows)
if rows
else '<tr><td colspan="2">No options stored.</td></tr>'
)
+ "</tbody></table></div>"
)
def _ai_variant_detail_body(variant: Item, basis_item: Item | None) -> str:
explanation = _html_to_text(variant.explanation) if variant.explanation else "-"
basis_preview = "-"
if basis_item is not None:
basis_preview = (
f"#{basis_item.id} | Tryout {escape(str(basis_item.tryout_id))} | "
f"Slot {basis_item.slot} | {escape(_truncate(_html_to_text(basis_item.stem), 160))}"
)
review_url = (
f"/admin/questions/{variant.basis_item_id}/generate?tab=review"
if variant.basis_item_id
else "/admin/basis-items"
)
if variant.generation_run_id:
review_url = f"{review_url}&run_id={variant.generation_run_id}"
return f"""
<div class="compact-strip">
<div class="compact-stat"><span>Item</span><strong>{variant.id}</strong></div>
<div class="compact-stat"><span>Batch</span><strong>{variant.generation_run_id or "-"}</strong></div>
<div class="compact-stat"><span>Level</span><strong>{escape(variant.level)}</strong></div>
<div class="compact-stat"><span>Status</span><strong>{escape(variant.variant_status)}</strong></div>
</div>
<div class="question-block">
<h3>Question</h3>
<p>{escape(_html_to_text(variant.stem))}</p>
</div>
<h3>Answer Options</h3>
{_options_table(variant.options, variant.correct_answer)}
<div class="question-block">
<h3>Correct Answer</h3>
<p><strong>{escape(variant.correct_answer)}</strong></p>
<h3>Pembahasan</h3>
<p>{escape(explanation)}</p>
</div>
<div class="question-block">
<h3>Generation Context</h3>
<p class="muted">Basis item: <strong>{basis_preview}</strong></p>
<p class="muted">Model: <strong>{escape(variant.ai_model or "-")}</strong></p>
<p class="muted">Created at: <strong>{escape(str(variant.created_at))}</strong></p>
</div>
<form method="post" action="/admin/questions/{variant.basis_item_id}/generate/review-bulk?tab=review">
<input type="hidden" name="item_ids" value="{variant.id}">
<div class="actions">
<select name="action" style="max-width:260px">
<option value="approved">Approve this item</option>
<option value="rejected">Reject this item</option>
<option value="archived">Archive this item</option>
<option value="stale">Mark stale</option>
<option value="active">Activate this item</option>
</select>
<button type="submit">Apply</button>
<a class="secondary-link" href="{review_url}">Back to Review Queue</a>
</div>
</form>
"""
@router.get("/questions/{item_id}/generate")
async def question_generate_view(
request: Request,
item_id: int,
tab: str = "generate",
status: str = "",
level: str = "",
run_id: str = "",
db: AsyncSession = Depends(get_db),
):
admin = await _current_admin(request)
if not admin:
return _login_redirect()
result = await db.execute(select(Item).where(Item.id == item_id))
item = result.scalar_one_or_none()
if not item:
return RedirectResponse(url="/admin/questions", status_code=HTTP_303_SEE_OTHER)
stats = await get_ai_stats(db)
# Fetch runs and variants specific to this item
runs_result = await db.execute(
select(AIGenerationRun)
.where(AIGenerationRun.basis_item_id == item.id)
.order_by(AIGenerationRun.created_at.desc())
)
generation_runs = list(runs_result.scalars().all())
stmt = select(Item).where(
Item.basis_item_id == item.id,
Item.variant_status != None,
)
if status:
stmt = stmt.where(Item.variant_status == status)
if level:
stmt = stmt.where(Item.level == level)
if run_id and run_id.isdigit():
stmt = stmt.where(Item.generation_run_id == int(run_id))
stmt = stmt.order_by(Item.created_at.desc())
variants_result = await db.execute(stmt)
generated_variants = list(variants_result.scalars().all())
body = _ai_form_body(
key_configured=bool(settings.OPENROUTER_API_KEY),
stats=stats,
item=item,
generation_runs=generation_runs,
generated_variants=generated_variants,
target_level="mudah",
ai_model=settings.OPENROUTER_MODEL_LLAMA,
generation_count="1",
operator_notes="",
include_note_for_admin=True,
include_note_in_prompt=False,
active_tab=tab,
variant_status_filter=status,
variant_level_filter=level,
variant_run_id_filter=run_id,
)
return _render_admin_page(
request, f"AI Workflow: #{item.id}", f"AI Workflow for #{item.id}", body
)
@router.post("/questions/{item_id}/generate")
async def question_generate_submit(
request: Request,
item_id: int,
db: AsyncSession = Depends(get_db),
target_level: str = Form("mudah"),
ai_model: str = Form(settings.OPENROUTER_MODEL_LLAMA),
generation_count: str = Form("1"),
operator_notes: str = Form(""),
include_note_for_admin: bool = Form(True),
include_note_in_prompt: bool = Form(False),
):
admin = await _current_admin(request)
if not admin:
return _login_redirect()
result = await db.execute(select(Item).where(Item.id == item_id))
item = result.scalar_one_or_none()
if not item:
return RedirectResponse(url="/admin/questions", status_code=HTTP_303_SEE_OTHER)
if not settings.OPENROUTER_API_KEY:
return RedirectResponse(
url=f"/admin/questions/{item.id}/generate?error=API key missing",
status_code=HTTP_303_SEE_OTHER,
)
count = int(generation_count) if generation_count.isdigit() else 1
from app.services.ai_generation import (
create_generation_run,
generate_questions_batch,
)
try:
# Create a generation run to track this batch
run_id = await create_generation_run(
basis_item_id=item.id,
target_level=target_level,
requested_count=count,
model=ai_model,
created_by=admin.username if admin else "unknown",
db=db,
source_snapshot_question_id=item.source_snapshot_question_id,
operator_notes=operator_notes,
)
# Generate the variants
generated = await generate_questions_batch(
basis_item=item,
target_level=target_level,
ai_model=ai_model,
count=count,
operator_notes=operator_notes,
)
except Exception as e:
return RedirectResponse(
url=f"/admin/questions/{item.id}/generate?error={str(e)}",
status_code=HTTP_303_SEE_OTHER,
)
saved_item_ids: list[int] = []
from app.schemas.ai import GeneratedQuestion
from app.services.ai_generation import save_ai_question
for generated_question in generated:
item_id_saved = await save_ai_question(
generated_data=GeneratedQuestion(
stem=generated_question.stem,
options=generated_question.options,
correct=generated_question.correct,
explanation=generated_question.explanation or None,
),
tryout_id=item.tryout_id,
website_id=item.website_id,
basis_item_id=item.id,
slot=item.slot,
level=target_level,
ai_model=ai_model,
generation_run_id=run_id,
source_snapshot_question_id=item.source_snapshot_question_id,
variant_status="draft",
db=db,
)
if item_id_saved:
saved_item_ids.append(item_id_saved)
await db.commit()
return RedirectResponse(
url=f"/admin/questions/{item.id}/generate?tab=review&run_id={run_id}",
status_code=HTTP_303_SEE_OTHER,
)
@router.get("/questions/{item_id}/generate/variants/{variant_id}")
async def ai_playground_variant_detail(
item_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
):
admin = await _current_admin(request)
if not admin:
return _login_redirect()
result = await db.execute(
select(Item).where(Item.id == item_id, Item.generated_by == "ai")
)
variant = result.scalar_one_or_none()
if variant is None:
body = """
<div class="error">Generated variant was not found.</div>
<a class="secondary-link" href="/admin/questions/{item.id}/generate?tab=review">Back to Review Queue</a>
"""
return _render_admin_page(
request, "Generated Variant", "Generated Variant", body
)
basis_item = None
if variant.basis_item_id:
basis_item = await db.get(Item, variant.basis_item_id)
body = _ai_variant_detail_body(variant, basis_item)
return _render_admin_page(
request,
f"Generated Variant #{variant.id}",
f"Generated Variant #{variant.id}",
body,
)
@router.post("/questions/{item_id}/generate/review-bulk")
async def question_generate_review_bulk(
request: Request,
item_id: int,
db: AsyncSession = Depends(get_db),
item_ids: list[int] = Form([]),
action: str = Form(...),
tab: str = "review",
):
admin = await _current_admin(request)
if not admin:
return _login_redirect()
valid_actions = {"approved", "rejected", "archived", "stale", "active"}
if action not in valid_actions:
return RedirectResponse(
url=f"/admin/questions/{item_id}/generate?tab={tab}&error=Invalid action",
status_code=HTTP_303_SEE_OTHER,
)
if not item_ids:
return RedirectResponse(
url=f"/admin/questions/{item_id}/generate?tab={tab}&error=No items selected",
status_code=HTTP_303_SEE_OTHER,
)
result = await db.execute(select(Item).where(Item.id.in_(item_ids)))
variants = list(result.scalars().all())
now = datetime.now(timezone.utc)
for v in variants:
v.variant_status = action
v.reviewed_by = admin.username
v.reviewed_at = now
v.updated_at = now
await db.commit()
return RedirectResponse(
url=f"/admin/questions/{item_id}/generate?tab={tab}&success=Successfully applied {action} to {len(variants)} variants.",
status_code=HTTP_303_SEE_OTHER,
)
@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()