6457 lines
266 KiB
Python
6457 lines
266 KiB
Python
"""
|
||
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="snapshot_question_ids"]\').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="item_ids"]\').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 > 0.70)</option>
|
||
<option value="medium" {difficulty_selected}>Medium (0.30 - 0.70)</option>
|
||
<option value="hard" {difficulty_selected}>Hard (p < 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="item_ids"]').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()
|