Add admin data hierarchy view
This commit is contained in:
371
app/admin_web.py
371
app/admin_web.py
@@ -118,6 +118,45 @@ def _dashboard_redirect() -> RedirectResponse:
|
||||
return RedirectResponse(url="/admin/dashboard", status_code=HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
ADMIN_NAV_ITEMS = (
|
||||
("Dashboard", "/admin/dashboard", ("/admin/dashboard",)),
|
||||
("Websites", "/admin/websites", ("/admin/websites",)),
|
||||
("Tryout Import", "/admin/tryout-import", ("/admin/tryout-import", "/admin/snapshot-questions")),
|
||||
("Data Hierarchy", "/admin/hierarchy", ("/admin/hierarchy",)),
|
||||
("Basis Items", "/admin/basis-items", ("/admin/basis-items",)),
|
||||
("Calibration Status", "/admin/calibration-status", ("/admin/calibration-status",)),
|
||||
("Item Statistics", "/admin/item-statistics", ("/admin/item-statistics",)),
|
||||
("Session Overview", "/admin/session-overview", ("/admin/session-overview",)),
|
||||
("AI Playground", "/admin/ai-playground", ("/admin/ai-playground",)),
|
||||
("Password Info", "/admin/password", ("/admin/password",)),
|
||||
("Logout", "/admin/logout", ("/admin/logout",)),
|
||||
)
|
||||
|
||||
|
||||
def _is_admin_nav_active(
|
||||
current_path: str,
|
||||
nav_path: str,
|
||||
child_prefixes: tuple[str, ...],
|
||||
) -> bool:
|
||||
if current_path == nav_path:
|
||||
return True
|
||||
for prefix in child_prefixes:
|
||||
if current_path == prefix or current_path.startswith(f"{prefix}/"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _admin_nav_links(request: Request) -> str:
|
||||
current_path = request.url.path
|
||||
links = []
|
||||
for label, path, child_prefixes in ADMIN_NAV_ITEMS:
|
||||
active = _is_admin_nav_active(current_path, path, child_prefixes)
|
||||
class_attr = ' class="active"' if active else ""
|
||||
aria = ' aria-current="page"' if active else ""
|
||||
links.append(f'<a href="{path}"{class_attr}{aria}>{escape(label)}</a>')
|
||||
return "\n ".join(links)
|
||||
|
||||
|
||||
def _render_auth_page(
|
||||
request: Request,
|
||||
title: str,
|
||||
@@ -176,6 +215,7 @@ def _render_auth_page(
|
||||
|
||||
|
||||
def _render_admin_page(request: Request, title: str, page_title: str, body: str) -> HTMLResponse:
|
||||
sidebar_links = _admin_nav_links(request)
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -189,6 +229,7 @@ def _render_admin_page(request: Request, title: str, page_title: str, body: str)
|
||||
.sidebar h1 {{ font-size: 18px; margin: 0 0 24px; }}
|
||||
.sidebar a {{ display: block; color: #cbd5e1; text-decoration: none; padding: 10px 12px; border-radius: 8px; margin-bottom: 8px; }}
|
||||
.sidebar a:hover {{ background: #1e293b; color: #fff; }}
|
||||
.sidebar a.active {{ background: #e2e8f0; color: #0f172a; font-weight: 800; }}
|
||||
.content {{ padding: 32px; }}
|
||||
.card {{ background: #fff; border-radius: 14px; padding: 24px; box-shadow: 0 8px 30px rgba(15, 23, 42, 0.08); }}
|
||||
.grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-top: 20px; }}
|
||||
@@ -232,6 +273,16 @@ def _render_admin_page(request: Request, title: str, page_title: str, body: str)
|
||||
.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; }}
|
||||
@media (max-width: 860px) {{
|
||||
.layout {{ grid-template-columns: 1fr; }}
|
||||
.sidebar {{ position: static; }}
|
||||
@@ -244,16 +295,7 @@ def _render_admin_page(request: Request, title: str, page_title: str, body: str)
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<h1>IRT Bank Soal Admin</h1>
|
||||
<a href="/admin/dashboard">Dashboard</a>
|
||||
<a href="/admin/websites">Websites</a>
|
||||
<a href="/admin/tryout-import">Tryout Import</a>
|
||||
<a href="/admin/basis-items">Basis Items</a>
|
||||
<a href="/admin/calibration-status">Calibration Status</a>
|
||||
<a href="/admin/item-statistics">Item Statistics</a>
|
||||
<a href="/admin/session-overview">Session Overview</a>
|
||||
<a href="/admin/ai-playground">AI Playground</a>
|
||||
<a href="/admin/password">Password Info</a>
|
||||
<a href="/admin/logout">Logout</a>
|
||||
{sidebar_links}
|
||||
</aside>
|
||||
<main class="content">
|
||||
<div class="card">
|
||||
@@ -637,6 +679,304 @@ async def _recent_generated_variants(
|
||||
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 sedang 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 = []
|
||||
for snapshot in snapshots_without_basis:
|
||||
rows.append(
|
||||
f'<li><span class="badge">Snapshot</span> {escape(snapshot.title)} '
|
||||
f'has no promoted basis items yet. '
|
||||
f'<a href="/admin/snapshot-questions?snapshot_id={snapshot.id}">Open snapshot questions</a></li>'
|
||||
)
|
||||
for item in basis_without_variants:
|
||||
rows.append(
|
||||
f'<li><span class="badge">Basis Item</span> #{item.id} has no generated variants yet. '
|
||||
f'<a href="/admin/basis-items/{item.id}">Open workspace</a></li>'
|
||||
)
|
||||
for item in variants_without_basis:
|
||||
rows.append(
|
||||
f'<li><span class="badge">Variant</span> #{item.id} is not linked to an existing basis item. '
|
||||
f'<a href="/admin/ai-playground/variants/{item.id}">View variant</a></li>'
|
||||
)
|
||||
for item in basis_missing_source:
|
||||
rows.append(
|
||||
f'<li><span class="badge">Basis Item</span> #{item.id} is missing a source snapshot question reference. '
|
||||
f'<a href="/admin/basis-items/{item.id}">Open workspace</a></li>'
|
||||
)
|
||||
|
||||
if not rows:
|
||||
return """
|
||||
<div class="success">No hierarchy gaps detected in the current data.</div>
|
||||
"""
|
||||
|
||||
return f"""
|
||||
<div class="question-block">
|
||||
<h3>Needs Attention</h3>
|
||||
<ul class="attention-list">{"".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
|
||||
latest_variant_links = []
|
||||
for variant in variants[:3]:
|
||||
latest_variant_links.append(
|
||||
f'<a class="secondary-link" href="/admin/ai-playground/variants/{variant.id}">'
|
||||
f'Variant #{variant.id}</a>'
|
||||
)
|
||||
source_label = "-"
|
||||
if source_question is not None:
|
||||
source_label = (
|
||||
f"{escape(source_question.source_question_id)} | "
|
||||
f"{escape(_truncate(_html_to_text(source_question.question_title or source_question.question_html), 120))}"
|
||||
)
|
||||
run_html = "-"
|
||||
if latest_run is not None:
|
||||
run_html = (
|
||||
f'Run #{latest_run.id} | {escape(latest_run.target_level)} | '
|
||||
f'{latest_run.requested_count} requested | '
|
||||
f'<a href="/admin/ai-playground?tab=review&run_id={latest_run.id}">Review run</a>'
|
||||
)
|
||||
variant_links_html = " ".join(latest_variant_links) if latest_variant_links else '<span class="muted">No variant detail links yet.</span>'
|
||||
|
||||
return f"""
|
||||
<div class="hierarchy-item">
|
||||
<p style="margin:0 0 8px;"><span class="badge">Basis Item</span> <strong>#{basis_item.id}</strong> | Slot {basis_item.slot} | Tryout {escape(basis_item.tryout_id)}</p>
|
||||
<p class="muted" style="margin:0 0 8px;">Source question: <strong>{source_label}</strong></p>
|
||||
<p class="muted" style="margin:0 0 8px;">Stem: {escape(_truncate(_html_to_text(basis_item.stem), 180))}</p>
|
||||
<p class="muted" style="margin:0 0 8px;">Variants: {_variant_status_counts_html(variants)}</p>
|
||||
<p class="muted" style="margin:0 0 8px;">Latest run: <strong>{run_html}</strong></p>
|
||||
<div class="actions" style="margin-top:10px;">
|
||||
<a class="secondary-link" href="/admin/basis-items/{basis_item.id}">Basis workspace</a>
|
||||
<a class="secondary-link" href="/admin/ai-playground?tab=review&basis_item_id={basis_item.id}">Review variants</a>
|
||||
{variant_links_html}
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
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),
|
||||
)
|
||||
basis_html = (
|
||||
"".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
|
||||
)
|
||||
if snapshot_basis
|
||||
else '<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],
|
||||
@@ -1336,6 +1676,17 @@ async def dashboard_view(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
return _render_admin_page(request, "IRT Bank Soal Admin", "Dashboard", body)
|
||||
|
||||
|
||||
@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 Hierarchy", "Data Hierarchy", body)
|
||||
|
||||
|
||||
@router.get("/websites", include_in_schema=False)
|
||||
async def websites_view(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
admin = await _current_admin(request)
|
||||
|
||||
Reference in New Issue
Block a user