Add tryout JSON importer to admin

This commit is contained in:
dwindown
2026-04-02 21:47:51 +07:00
parent 97db25aca8
commit d7f110d8d0

View File

@@ -13,7 +13,7 @@ import json
from typing import Any from typing import Any
import aioredis import aioredis
from fastapi import APIRouter, Depends, Form, Request from fastapi import APIRouter, Depends, File, Form, Request, UploadFile
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -22,7 +22,7 @@ from starlette.status import HTTP_303_SEE_OTHER, HTTP_401_UNAUTHORIZED
from app.core.config import get_settings from app.core.config import get_settings
from app.database import get_db from app.database import get_db
from app.models import Item, Session, Tryout, Website from app.models import Item, Session, Tryout, TryoutImportSnapshot, Website
from app.services.ai_generation import ( from app.services.ai_generation import (
SUPPORTED_MODELS, SUPPORTED_MODELS,
generate_question, generate_question,
@@ -31,12 +31,19 @@ from app.services.ai_generation import (
validate_ai_model, validate_ai_model,
) )
from app.services.irt_calibration import get_calibration_status 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() settings = get_settings()
router = APIRouter(prefix="/admin", tags=["admin-web"]) router = APIRouter(prefix="/admin", tags=["admin-web"])
SESSION_COOKIE = "access_token" SESSION_COOKIE = "access_token"
SESSION_PREFIX = "admin:session:" SESSION_PREFIX = "admin:session:"
IMPORT_PREVIEW_PREFIX = "admin:import-preview:"
IMPORT_PREVIEW_TTL_SECONDS = 900
_admin_redis = None _admin_redis = None
@@ -174,6 +181,7 @@ def _render_admin_page(title: str, page_title: str, body: str) -> HTMLResponse:
<h1>IRT Bank Soal Admin</h1> <h1>IRT Bank Soal Admin</h1>
<a href="/admin/dashboard">Dashboard</a> <a href="/admin/dashboard">Dashboard</a>
<a href="/admin/websites">Websites</a> <a href="/admin/websites">Websites</a>
<a href="/admin/tryout-import">Tryout Import</a>
<a href="/admin/calibration-status">Calibration Status</a> <a href="/admin/calibration-status">Calibration Status</a>
<a href="/admin/item-statistics">Item Statistics</a> <a href="/admin/item-statistics">Item Statistics</a>
<a href="/admin/session-overview">Session Overview</a> <a href="/admin/session-overview">Session Overview</a>
@@ -291,6 +299,108 @@ def _website_edit_form_body(
""" """
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 = [
[
snapshot.id,
f'{website_map.get(snapshot.website_id, "Unknown")} (#{snapshot.website_id})',
snapshot.source_tryout_id,
snapshot.title,
snapshot.question_count,
snapshot.created_at,
]
for snapshot in recent_snapshots
]
snapshots_table = _table(
["Snapshot ID", "Website", "Tryout ID", "Title", "Questions", "Imported At"],
snapshot_rows,
)
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}
"""
async def _basis_items_for_playground(db: AsyncSession, limit: int = 20) -> list[Item]: async def _basis_items_for_playground(db: AsyncSession, limit: int = 20) -> list[Item]:
result = await db.execute( result = await db.execute(
select(Item) select(Item)
@@ -370,6 +480,18 @@ async def _find_or_create_demo_basis_item(db: AsyncSession) -> Item:
return 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())
@router.get("", include_in_schema=False) @router.get("", include_in_schema=False)
@router.get("/", include_in_schema=False) @router.get("/", include_in_schema=False)
async def admin_root(request: Request): async def admin_root(request: Request):
@@ -727,6 +849,147 @@ async def website_delete_submit(
return _render_admin_page("Websites", "Websites", body) return _render_admin_page("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("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("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("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("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("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("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("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("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("Tryout Import", "Tryout Import", body)
@router.get("/calibration-status", include_in_schema=False) @router.get("/calibration-status", include_in_schema=False)
async def calibration_status_view(request: Request, db: AsyncSession = Depends(get_db)): async def calibration_status_view(request: Request, db: AsyncSession = Depends(get_db)):
admin = await _current_admin(request) admin = await _current_admin(request)