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
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user