Add tryout JSON importer to admin
This commit is contained in:
267
app/admin_web.py
267
app/admin_web.py
@@ -13,7 +13,7 @@ import json
|
||||
from typing import Any
|
||||
|
||||
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.exc import IntegrityError
|
||||
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.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 (
|
||||
SUPPORTED_MODELS,
|
||||
generate_question,
|
||||
@@ -31,12 +31,19 @@ from app.services.ai_generation import (
|
||||
validate_ai_model,
|
||||
)
|
||||
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"
|
||||
SESSION_PREFIX = "admin:session:"
|
||||
IMPORT_PREVIEW_PREFIX = "admin:import-preview:"
|
||||
IMPORT_PREVIEW_TTL_SECONDS = 900
|
||||
|
||||
_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>
|
||||
<a href="/admin/dashboard">Dashboard</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/item-statistics">Item Statistics</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]:
|
||||
result = await db.execute(
|
||||
select(Item)
|
||||
@@ -370,6 +480,18 @@ async def _find_or_create_demo_basis_item(db: AsyncSession) -> 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)
|
||||
async def admin_root(request: Request):
|
||||
@@ -727,6 +849,147 @@ async def website_delete_submit(
|
||||
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)
|
||||
async def calibration_status_view(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
admin = await _current_admin(request)
|
||||
|
||||
Reference in New Issue
Block a user