diff --git a/app/admin_web.py b/app/admin_web.py index f44cdf7..1c41510 100644 --- a/app/admin_web.py +++ b/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:

IRT Bank Soal Admin

Dashboard Websites + Tryout Import Calibration Status Item Statistics Session Overview @@ -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'
{escape(error)}
' if error else "" + success_html = f'
{escape(success)}
' if success else "" + + website_options = [''] + for website in websites: + selected = "selected" if selected_website_id == website.id else "" + website_options.append( + f'' + ) + + 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""" +
+ + + +
+ """ + + preview_html = f""" +

Preview Summary

+

File: {escape(upload_filename or "uploaded JSON")}

+
+
Tryouts{preview.get("tryout_count", 0)}
+
New Questions{totals.get("new_questions", 0)}
+
Updated Questions{totals.get("updated_questions", 0)}
+
Removed Questions{totals.get("removed_questions", 0)}
+
+ {_table( + ["Tryout ID", "Title", "Total", "New", "Updated", "Unchanged", "Removed", "Warnings"], + tryout_rows, + )} +
{import_form}
+ """ + + return f""" +

Import Sejoli tryout JSON as read-only snapshot reference data. This does not create live item-bank questions.

+

Use this when the source tryout changes upstream. Re-import updates matching source question IDs, inserts new ones, and marks missing ones inactive.

+ {success_html} + {error_html} +
+ + + + + +
+ {preview_html} +

Recent Snapshots

+

These are archived imports stored in PostgreSQL for traceability.

+ {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)