""" Admin API router for custom admin actions. Provides admin-specific endpoints for triggering calibration, toggling AI generation, and resetting normalization. """ from datetime import datetime, timezone from typing import Any, Dict, Literal from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.core.auth import AuthContext, ensure_website_scope_matches, get_auth_context, require_website_auth from app.core.config import get_settings from app.database import get_db from app.models import AIGenerationRun, Item, Tryout, TryoutImportSnapshot, TryoutSnapshotQuestion, TryoutStats, UserAnswer, Website from app.services.irt_calibration import ( calibrate_all, CALIBRATION_SAMPLE_THRESHOLD, ) router = APIRouter(prefix="/admin", tags=["admin"]) settings = get_settings() class BulkReviewRequest(BaseModel): item_ids: list[int] = Field(..., min_length=1) status: Literal["active", "approved", "rejected", "archived", "stale"] review_notes: str | None = None class SnapshotPromoteRequest(BaseModel): snapshot_question_ids: list[int] = Field(..., min_length=1) def _snapshot_slot_map(snapshot: TryoutImportSnapshot) -> dict[str, int]: slot_map: dict[str, int] = {} questions = (snapshot.raw_payload or {}).get("questions") or [] for index, question in enumerate(questions, start=1): source_question_id = str((question or {}).get("id") or "").strip() if source_question_id: slot_map[source_question_id] = index return slot_map def _snapshot_options_to_item_options(raw_options: list[dict[str, Any]] | list[Any]) -> dict[str, str]: item_options: dict[str, str] = {} for option in raw_options or []: if not isinstance(option, dict): continue increment = str(option.get("increment") or "").strip().upper() text = str(option.get("text") or option.get("label") or "").strip() if increment and text: item_options[increment] = text return item_options def _serialize_item(item: Item, include_content: bool = False) -> dict[str, Any]: payload: dict[str, Any] = { "id": item.id, "item_id": str(item.id), "tryout_id": item.tryout_id, "website_id": item.website_id, "slot": item.slot, "level": item.level, "stem_text": item.stem, "p_value": item.ctt_p, "ctt_bobot": item.ctt_bobot, "ctt_category": item.ctt_category, "irt_b": item.irt_b, "irt_se": item.irt_se, "calibrated": item.calibrated, "calibration_sample_size": item.calibration_sample_size, "generated_by": item.generated_by, "ai_model": item.ai_model, "basis_item_id": item.basis_item_id, "generation_run_id": item.generation_run_id, "source_snapshot_question_id": item.source_snapshot_question_id, "variant_status": item.variant_status, "created_at": item.created_at, "updated_at": item.updated_at, } if include_content: payload.update( { "stem": item.stem, "options": item.options, "correct_answer": item.correct_answer, "explanation": item.explanation, "reviewed_by": item.reviewed_by, "reviewed_at": item.reviewed_at, "review_notes": item.review_notes, } ) return payload def _serialize_ai_run(run: AIGenerationRun, basis: Item | None = None) -> dict[str, Any]: generated_items = list(run.generated_items or []) pending_count = sum(1 for item in generated_items if item.variant_status == "draft") reviewed_count = sum(1 for item in generated_items if item.variant_status != "draft") if pending_count: run_status = "pending_review" elif generated_items: run_status = "completed" else: run_status = "created" return { "id": run.id, "basis_item_id": run.basis_item_id, "target_level": run.target_level, "requested_count": run.requested_count, "model": run.model, "created_by": run.created_by, "created_at": run.created_at, "status": run_status, "generated_count": len(generated_items), "pending_review_count": pending_count, "reviewed_count": reviewed_count, "basis_tryout_id": basis.tryout_id if basis else None, "basis_slot": basis.slot if basis else None, } async def _ensure_operational_tryout(snapshot: TryoutImportSnapshot, db: AsyncSession) -> Tryout: result = await db.execute( select(Tryout).where( Tryout.website_id == snapshot.website_id, Tryout.tryout_id == snapshot.source_tryout_id, ) ) tryout = result.scalar_one_or_none() if tryout: return tryout tryout = Tryout( website_id=snapshot.website_id, tryout_id=snapshot.source_tryout_id, name=snapshot.title, description=f"Operational tryout basis created from imported snapshot #{snapshot.id}.", scoring_mode="ctt", selection_mode="fixed", normalization_mode="static", ai_generation_enabled=True, ) db.add(tryout) await db.flush() return tryout async def _promote_snapshot_question_to_item( snapshot: TryoutImportSnapshot, question: TryoutSnapshotQuestion, db: AsyncSession, ) -> tuple[Item | None, str]: if question.website_id != snapshot.website_id or question.source_tryout_id != snapshot.source_tryout_id: return None, "mismatch" slot_map = _snapshot_slot_map(snapshot) slot = slot_map.get(question.source_question_id) if not slot: max_slot = ( await db.scalar( select(func.max(Item.slot)).where( Item.website_id == snapshot.website_id, Item.tryout_id == snapshot.source_tryout_id, Item.level == "sedang", ) ) or 0 ) slot = max_slot + 1 options = _snapshot_options_to_item_options(question.raw_options) if not options: return None, "missing_options" await _ensure_operational_tryout(snapshot, db) existing_item_result = await db.execute( select(Item).where( Item.website_id == snapshot.website_id, Item.tryout_id == snapshot.source_tryout_id, Item.slot == slot, Item.level == "sedang", ) ) existing_item = existing_item_result.scalar_one_or_none() if existing_item is not None: return existing_item, "existing" item = Item( tryout_id=snapshot.source_tryout_id, website_id=snapshot.website_id, slot=slot, level="sedang", stem=question.question_html, options=options, correct_answer=question.correct_answer, explanation=question.explanation_html, generated_by="manual", source_snapshot_question_id=question.id, variant_status="active", calibrated=False, calibration_sample_size=0, ) db.add(item) await db.flush() return item, "created" @router.post( "/{tryout_id}/calibrate", summary="Trigger IRT calibration", description="Trigger IRT calibration for all items in this tryout with sufficient response data.", ) async def admin_trigger_calibration( tryout_id: str, db: AsyncSession = Depends(get_db), auth: AuthContext = Depends(get_auth_context), ) -> Dict[str, Any]: """ Trigger IRT calibration for all items in a tryout. Runs calibration for items with >= min_calibration_sample responses. Updates item.irt_b, item.irt_se, and item.calibrated status. Args: tryout_id: Tryout identifier db: Database session website_id: Website ID from header Returns: Calibration results summary Raises: HTTPException: If tryout not found or calibration fails """ website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) # Verify tryout exists query = select(Tryout).where(Tryout.tryout_id == tryout_id) if website_id is not None: query = query.where(Tryout.website_id == website_id) tryout_result = await db.execute(query) tryout = tryout_result.scalar_one_or_none() if tryout is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Tryout {tryout_id} not found for website {website_id}", ) # Run calibration result = await calibrate_all( tryout_id=tryout_id, website_id=website_id, db=db, min_sample_size=tryout.min_calibration_sample or CALIBRATION_SAMPLE_THRESHOLD, ) return { "tryout_id": tryout_id, "total_items": result.total_items, "calibrated_items": result.calibrated_items, "failed_items": result.failed_items, "calibration_percentage": round(result.calibration_percentage * 100, 2), "ready_for_irt": result.ready_for_irt, "message": f"Calibration complete: {result.calibrated_items}/{result.total_items} items calibrated", } @router.post( "/{tryout_id}/toggle-ai-generation", summary="Toggle AI generation", description="Toggle AI question generation for a tryout.", ) async def admin_toggle_ai_generation( tryout_id: str, db: AsyncSession = Depends(get_db), auth: AuthContext = Depends(get_auth_context), ) -> Dict[str, Any]: """ Toggle AI generation for a tryout. Updates Tryout.AI_generation_enabled field. Args: tryout_id: Tryout identifier db: Database session website_id: Website ID from header Returns: Updated AI generation status Raises: HTTPException: If tryout not found """ website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) # Get tryout query = select(Tryout).where(Tryout.tryout_id == tryout_id) if website_id is not None: query = query.where(Tryout.website_id == website_id) result = await db.execute(query) tryout = result.scalar_one_or_none() if tryout is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Tryout {tryout_id} not found for website {website_id}", ) # Toggle AI generation tryout.ai_generation_enabled = not tryout.ai_generation_enabled await db.commit() await db.refresh(tryout) status = "enabled" if tryout.ai_generation_enabled else "disabled" return { "tryout_id": tryout_id, "ai_generation_enabled": tryout.ai_generation_enabled, "message": f"AI generation {status} for tryout {tryout_id}", } @router.post( "/{tryout_id}/reset-normalization", summary="Reset normalization", description="Reset normalization to static values and clear incremental stats.", ) async def admin_reset_normalization( tryout_id: str, db: AsyncSession = Depends(get_db), auth: AuthContext = Depends(get_auth_context), ) -> Dict[str, Any]: """ Reset normalization for a tryout. Resets rataan, sb to static values and clears incremental stats. Args: tryout_id: Tryout identifier db: Database session website_id: Website ID from header Returns: Reset statistics Raises: HTTPException: If tryout or stats not found """ website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) # Get tryout stats stats_query = select(TryoutStats).where(TryoutStats.tryout_id == tryout_id) if website_id is not None: stats_query = stats_query.where(TryoutStats.website_id == website_id) stats_result = await db.execute(stats_query) stats = stats_result.scalar_one_or_none() if stats is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"TryoutStats for {tryout_id} not found for website {website_id}", ) # Get tryout for static values tryout_query = select(Tryout).where(Tryout.tryout_id == tryout_id) if website_id is not None: tryout_query = tryout_query.where(Tryout.website_id == website_id) tryout_result = await db.execute(tryout_query) tryout = tryout_result.scalar_one_or_none() if tryout: # Reset to static values stats.rataan = tryout.static_rataan stats.sb = tryout.static_sb else: # Reset to default values stats.rataan = 500.0 stats.sb = 100.0 # Clear incremental stats old_participant_count = stats.participant_count stats.participant_count = 0 stats.total_nm_sum = 0.0 stats.total_nm_sq_sum = 0.0 stats.min_nm = None stats.max_nm = None stats.last_calculated = None await db.commit() await db.refresh(stats) return { "tryout_id": tryout_id, "rataan": stats.rataan, "sb": stats.sb, "cleared_stats": { "previous_participant_count": old_participant_count, }, "message": f"Normalization reset to static values (rataan={stats.rataan}, sb={stats.sb}). Incremental stats cleared.", } @router.get( "/tryouts/{tryout_id}/questions", summary="Get tryout questions", description="Retrieve all questions/items for a specific tryout for admin management.", ) async def admin_get_tryout_questions( tryout_id: str, db: AsyncSession = Depends(get_db), auth: AuthContext = Depends(get_auth_context), ) -> Dict[str, Any]: """Retrieve questions/items for a tryout.""" website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) from app.models.item import Item from app.models.tryout_snapshot_question import TryoutSnapshotQuestion query = select(Item).where(Item.tryout_id == tryout_id).order_by(Item.id) if website_id is not None: query = query.where(Item.website_id == website_id) result = await db.execute(query) items = result.scalars().all() snapshot_query = select(TryoutSnapshotQuestion).where( TryoutSnapshotQuestion.source_tryout_id == tryout_id ).order_by(TryoutSnapshotQuestion.id) if website_id is not None: snapshot_query = snapshot_query.where(TryoutSnapshotQuestion.website_id == website_id) snapshot_result = await db.execute(snapshot_query) snapshots = snapshot_result.scalars().all() promoted_by_snapshot_question_id = { item.source_snapshot_question_id: item for item in items if item.source_snapshot_question_id is not None } return { "tryout_id": tryout_id, "items": [ _serialize_item(i) for i in items ], "snapshot_questions": [ { "id": s.id, "latest_snapshot_id": s.latest_snapshot_id, "source_question_id": s.source_question_id, "question_title": s.question_title, "question_html": s.question_html, "explanation_html": s.explanation_html, "option_count": s.option_count, "has_option_labels": s.has_option_labels, "correct_answer": s.correct_answer, "is_active": s.is_active, "promoted_item": _serialize_item(promoted_by_snapshot_question_id[s.id]) if s.id in promoted_by_snapshot_question_id else None, "created_at": s.created_at, } for s in snapshots ] } @router.get( "/tryouts/{tryout_id}/attempts", summary="Get tryout attempts", description="Retrieve all student attempts (sessions) for a specific tryout.", ) async def admin_get_tryout_attempts( tryout_id: str, db: AsyncSession = Depends(get_db), auth: AuthContext = Depends(get_auth_context), ) -> Dict[str, Any]: """Retrieve student attempts/sessions for a tryout.""" website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) from app.models.session import Session query = select(Session).where(Session.tryout_id == tryout_id).order_by(Session.created_at.desc()) if website_id is not None: query = query.where(Session.website_id == website_id) result = await db.execute(query) sessions = result.scalars().all() return { "tryout_id": tryout_id, "attempts": [ { "id": s.id, "session_id": s.session_id, "wp_user_id": s.wp_user_id, "start_time": s.start_time, "end_time": s.end_time, "expires_at": s.expires_at, "is_completed": s.is_completed, "scoring_mode_used": s.scoring_mode_used, "NM": s.NM, "NN": s.NN, "total_benar": s.total_benar, } for s in sessions ] } @router.get( "/dashboard/stats", summary="Get dashboard statistics", description="Retrieve aggregated system metrics for the admin dashboard.", ) async def admin_get_dashboard_stats( db: AsyncSession = Depends(get_db), auth: AuthContext = Depends(get_auth_context), ) -> Dict[str, Any]: """Retrieve overview metrics for the dashboard.""" website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) from sqlalchemy import func from app.models import Tryout, Item, Session, AIGenerationRun # Base queries with website filtering tryouts_q = select(func.count()).select_from(Tryout) items_q = select(func.count()).select_from(Item) sessions_q = select(func.count()).select_from(Session) completed_q = select(func.count()).select_from(Session).where(Session.is_completed.is_(True)) uncalibrated_q = select(func.count()).select_from(Item).where(Item.calibrated.is_(False)) if website_id is not None: tryouts_q = tryouts_q.where(Tryout.website_id == website_id) items_q = items_q.where(Item.website_id == website_id) sessions_q = sessions_q.where(Session.website_id == website_id) completed_q = completed_q.where(Session.website_id == website_id) uncalibrated_q = uncalibrated_q.where(Item.website_id == website_id) # Execute counts tryouts_count = await db.scalar(tryouts_q) or 0 items_count = await db.scalar(items_q) or 0 sessions_count = await db.scalar(sessions_q) or 0 completed_count = await db.scalar(completed_q) or 0 uncalibrated_count = await db.scalar(uncalibrated_q) or 0 # Recent sessions recent_sessions_q = select(Session).where(Session.is_completed.is_(True)).order_by(Session.end_time.desc()).limit(5) if website_id is not None: recent_sessions_q = recent_sessions_q.where(Session.website_id == website_id) recent_sessions_result = await db.execute(recent_sessions_q) recent_sessions = recent_sessions_result.scalars().all() # AI stats (from AIGenerationRun) - note: AI runs don't have website_id currently, but we can filter by basis_item.website_id if joined. For now we will return all. recent_runs_q = ( select(AIGenerationRun) .options(selectinload(AIGenerationRun.generated_items)) .order_by(AIGenerationRun.id.desc()) .limit(3) ) recent_runs_result = await db.execute(recent_runs_q) recent_runs = recent_runs_result.scalars().all() basis_ids = [run.basis_item_id for run in recent_runs if run.basis_item_id is not None] basis_by_id: dict[int, Item] = {} if basis_ids: basis_result = await db.execute(select(Item).where(Item.id.in_(basis_ids))) basis_by_id = {item.id: item for item in basis_result.scalars().all()} # Calculate calibration status calibrated_count = items_count - uncalibrated_count calibration_percentage = round((calibrated_count / items_count * 100) if items_count > 0 else 0, 2) # Calculate completion rate completion_rate = round((completed_count / sessions_count * 100) if sessions_count > 0 else 0, 2) return { "metrics": { "tryouts": tryouts_count, "items": items_count, "sessions": sessions_count, "completed_sessions": completed_count, "completion_rate": completion_rate, "calibration_percentage": calibration_percentage, }, "recent_sessions": [ { "id": s.id, "wp_user_id": s.wp_user_id, "tryout_id": s.tryout_id, "end_time": s.end_time, "NM": s.NM, "NN": s.NN } for s in recent_sessions ], "recent_ai_runs": [ _serialize_ai_run(run, basis_by_id.get(run.basis_item_id)) for run in recent_runs if website_id is None or ( basis_by_id.get(run.basis_item_id) is not None and basis_by_id[run.basis_item_id].website_id == website_id ) ] } @router.get( "/questions", summary="Get all questions", description="Retrieve all questions across all tryouts.", ) async def admin_get_all_questions( db: AsyncSession = Depends(get_db), auth: AuthContext = Depends(get_auth_context), ) -> Dict[str, Any]: """Retrieve all questions/items.""" website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) from app.models.item import Item query = select(Item).order_by(Item.id.desc()).limit(500) if website_id is not None: query = query.where(Item.website_id == website_id) result = await db.execute(query) items = result.scalars().all() return { "items": [ _serialize_item(i) for i in items ] } @router.get( "/questions/{item_id}", summary="Get question detail", description="Retrieve one question with options, explanation, basis item, and generated variants.", ) async def admin_get_question_detail( item_id: int, db: AsyncSession = Depends(get_db), auth: AuthContext = Depends(get_auth_context), ) -> Dict[str, Any]: website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) result = await db.execute( select(Item) .options(selectinload(Item.variants), selectinload(Item.basis_item)) .where(Item.id == item_id) ) item = result.scalar_one_or_none() if item is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Question not found") ensure_website_scope_matches(website_id, item.website_id) usage_result = await db.execute( select( func.count(UserAnswer.id), func.count(func.distinct(UserAnswer.wp_user_id)), ).where(UserAnswer.item_id == item.id) ) impressions, unique_users = usage_result.one() return { "item": _serialize_item(item, include_content=True), "basis_item": _serialize_item(item.basis_item, include_content=True) if item.basis_item else None, "variants": [_serialize_item(variant, include_content=True) for variant in item.variants], "usage": { "impressions": int(impressions or 0), "unique_users": int(unique_users or 0), }, } @router.get( "/questions/{item_id}/variants", summary="Get question variants", description="Retrieve generated variants for a basis question.", ) async def admin_get_question_variants( item_id: int, db: AsyncSession = Depends(get_db), auth: AuthContext = Depends(get_auth_context), ) -> Dict[str, Any]: website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) basis = await db.get(Item, item_id) if basis is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Question not found") ensure_website_scope_matches(website_id, basis.website_id) result = await db.execute( select(Item) .where(Item.basis_item_id == item_id) .order_by(Item.created_at.desc(), Item.id.desc()) ) return {"items": [_serialize_item(item, include_content=True) for item in result.scalars().all()]} @router.get( "/snapshots", summary="List imported tryout snapshots", description="List imported JSON snapshots for the selected website.", ) async def admin_list_snapshots( db: AsyncSession = Depends(get_db), auth: AuthContext = Depends(get_auth_context), ) -> Dict[str, Any]: website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) query = select(TryoutImportSnapshot).order_by(TryoutImportSnapshot.id.desc()).limit(100) if website_id is not None: query = query.where(TryoutImportSnapshot.website_id == website_id) result = await db.execute(query) snapshots = result.scalars().all() return { "snapshots": [ { "id": snapshot.id, "website_id": snapshot.website_id, "source_tryout_id": snapshot.source_tryout_id, "source_key": snapshot.source_key, "title": snapshot.title, "question_count": snapshot.question_count, "result_count": snapshot.result_count, "created_at": snapshot.created_at, } for snapshot in snapshots ] } @router.get( "/snapshots/{snapshot_id}/questions", summary="List snapshot questions", description="List read-only source questions and promotion status for a snapshot.", ) async def admin_list_snapshot_questions( snapshot_id: int, db: AsyncSession = Depends(get_db), auth: AuthContext = Depends(get_auth_context), ) -> Dict[str, Any]: website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) snapshot = await db.get(TryoutImportSnapshot, snapshot_id) if snapshot is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Snapshot not found") ensure_website_scope_matches(website_id, snapshot.website_id) slot_map = _snapshot_slot_map(snapshot) question_result = await db.execute( select(TryoutSnapshotQuestion) .where( TryoutSnapshotQuestion.website_id == snapshot.website_id, TryoutSnapshotQuestion.source_tryout_id == snapshot.source_tryout_id, ) .order_by(TryoutSnapshotQuestion.source_question_id.asc()) ) questions = list(question_result.scalars().all()) questions.sort(key=lambda row: (slot_map.get(row.source_question_id, 10**9), row.source_question_id)) item_result = await db.execute( select(Item).where( Item.website_id == snapshot.website_id, Item.tryout_id == snapshot.source_tryout_id, Item.level == "sedang", ) ) promoted_items_by_slot = {item.slot: item for item in item_result.scalars().all()} return { "snapshot": { "id": snapshot.id, "website_id": snapshot.website_id, "source_tryout_id": snapshot.source_tryout_id, "title": snapshot.title, "question_count": snapshot.question_count, "created_at": snapshot.created_at, }, "questions": [ { "id": question.id, "slot": slot_map.get(question.source_question_id), "source_question_id": question.source_question_id, "question_title": question.question_title, "question_html": question.question_html, "correct_answer": question.correct_answer, "option_count": question.option_count, "has_option_labels": question.has_option_labels, "is_active": question.is_active, "promoted_item": _serialize_item(promoted_items_by_slot[slot_map[question.source_question_id]]) if slot_map.get(question.source_question_id) in promoted_items_by_slot else None, } for question in questions ], } @router.post( "/snapshots/{snapshot_id}/promote", summary="Promote snapshot questions", description="Promote selected snapshot questions into live medium-level basis items.", ) async def admin_promote_snapshot_questions( snapshot_id: int, request: SnapshotPromoteRequest, db: AsyncSession = Depends(get_db), auth: AuthContext = Depends(get_auth_context), ) -> Dict[str, Any]: website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) snapshot = await db.get(TryoutImportSnapshot, snapshot_id) if snapshot is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Snapshot not found") ensure_website_scope_matches(website_id, snapshot.website_id) result = await db.execute( select(TryoutSnapshotQuestion).where(TryoutSnapshotQuestion.id.in_(request.snapshot_question_ids)) ) questions = list(result.scalars().all()) requested_ids = set(request.snapshot_question_ids) found_ids = {question.id for question in questions} missing_ids = sorted(requested_ids - found_ids) if missing_ids: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail={"message": "Some snapshot questions were not found", "ids": missing_ids}, ) rows = [] for question in questions: item, row_status = await _promote_snapshot_question_to_item(snapshot, question, db) rows.append( { "snapshot_question_id": question.id, "status": row_status, "item": _serialize_item(item) if item else None, } ) await db.commit() return {"snapshot_id": snapshot_id, "results": rows} @router.get( "/overview/hierarchy", summary="Get data hierarchy overview", description="Return website, snapshot, source question, basis, run, and variant hierarchy data.", ) async def admin_get_hierarchy_overview( db: AsyncSession = Depends(get_db), auth: AuthContext = Depends(get_auth_context), ) -> Dict[str, Any]: website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) websites_query = select(Website).order_by(Website.id.asc()) snapshots_query = select(TryoutImportSnapshot).order_by(TryoutImportSnapshot.id.desc()) questions_query = select(TryoutSnapshotQuestion).order_by(TryoutSnapshotQuestion.id.asc()) basis_query = select(Item).where(Item.generated_by != "ai", Item.level == "sedang") variants_query = select(Item).where(Item.generated_by == "ai") runs_query = select(AIGenerationRun).order_by(AIGenerationRun.id.desc()) if website_id is not None: websites_query = websites_query.where(Website.id == website_id) snapshots_query = snapshots_query.where(TryoutImportSnapshot.website_id == website_id) questions_query = questions_query.where(TryoutSnapshotQuestion.website_id == website_id) basis_query = basis_query.where(Item.website_id == website_id) variants_query = variants_query.where(Item.website_id == website_id) websites = list((await db.execute(websites_query)).scalars().all()) snapshots = list((await db.execute(snapshots_query)).scalars().all()) source_questions = list((await db.execute(questions_query)).scalars().all()) basis_items = list((await db.execute(basis_query)).scalars().all()) variants = list((await db.execute(variants_query)).scalars().all()) runs = list((await db.execute(runs_query)).scalars().all()) basis_by_source: dict[int, list[Item]] = {} variants_by_basis: dict[int, list[Item]] = {} for item in basis_items: if item.source_snapshot_question_id is not None: basis_by_source.setdefault(item.source_snapshot_question_id, []).append(item) for item in variants: if item.basis_item_id is not None: variants_by_basis.setdefault(item.basis_item_id, []).append(item) snapshots_without_basis = 0 for snapshot in snapshots: snapshot_question_ids = [ question.id for question in source_questions if question.latest_snapshot_id == snapshot.id ] if snapshot_question_ids and not any(basis_by_source.get(question_id) for question_id in snapshot_question_ids): snapshots_without_basis += 1 return { "summary": { "websites": len(websites), "snapshots": len(snapshots), "source_questions": len(source_questions), "basis_items": len(basis_items), "ai_runs": len(runs), "variants": len(variants), "snapshots_without_basis": snapshots_without_basis, "basis_without_variants": sum(1 for item in basis_items if not variants_by_basis.get(item.id)), "orphan_variants": sum(1 for item in variants if item.basis_item_id is None or item.basis_item_id not in {basis.id for basis in basis_items}), }, "websites": [ { "id": website.id, "name": website.site_name, "domain": website.site_url, "snapshots": [ { "id": snapshot.id, "tryout_id": snapshot.source_tryout_id, "title": snapshot.title, "question_count": snapshot.question_count, "created_at": snapshot.created_at, "basis_items": [ _serialize_item(item) for question in source_questions if question.latest_snapshot_id == snapshot.id for item in basis_by_source.get(question.id, []) ], } for snapshot in snapshots if snapshot.website_id == website.id ], } for website in websites ], } @router.get( "/ai/runs", summary="Get AI generation run history", description="List AI generation runs for admin review.", ) async def admin_get_ai_runs( db: AsyncSession = Depends(get_db), auth: AuthContext = Depends(get_auth_context), ) -> Dict[str, Any]: website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) query = ( select(AIGenerationRun) .options(selectinload(AIGenerationRun.generated_items)) .order_by(AIGenerationRun.id.desc()) .limit(100) ) result = await db.execute(query) runs = result.scalars().all() basis_ids = [run.basis_item_id for run in runs if run.basis_item_id is not None] basis_by_id: dict[int, Item] = {} if basis_ids: basis_result = await db.execute(select(Item).where(Item.id.in_(basis_ids))) basis_by_id = {item.id: item for item in basis_result.scalars().all()} rows = [] for run in runs: basis = basis_by_id.get(run.basis_item_id) if website_id is not None and (basis is None or basis.website_id != website_id): continue rows.append(_serialize_ai_run(run, basis)) return {"runs": rows} @router.get( "/ai/variants", summary="List AI generated variants", description="List generated variants with optional basis/tryout/status filters.", ) async def admin_get_ai_variants( tryout_id: str | None = None, basis_item_id: int | None = None, status_filter: str | None = None, db: AsyncSession = Depends(get_db), auth: AuthContext = Depends(get_auth_context), ) -> Dict[str, Any]: website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) query = select(Item).where(Item.generated_by == "ai").order_by(Item.created_at.desc()).limit(300) if website_id is not None: query = query.where(Item.website_id == website_id) if tryout_id: query = query.where(Item.tryout_id == tryout_id) if basis_item_id is not None: query = query.where(Item.basis_item_id == basis_item_id) if status_filter: query = query.where(Item.variant_status == status_filter) result = await db.execute(query) return {"items": [_serialize_item(item, include_content=True) for item in result.scalars().all()]} @router.post( "/ai/review-bulk", summary="Bulk review AI generated variants", description="Apply a review status to multiple AI generated variants.", ) async def admin_bulk_review_ai_questions( request: BulkReviewRequest, db: AsyncSession = Depends(get_db), auth: AuthContext = Depends(get_auth_context), ) -> Dict[str, Any]: website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) result = await db.execute(select(Item).where(Item.id.in_(request.item_ids), Item.generated_by == "ai")) items = list(result.scalars().all()) found_ids = {item.id for item in items} missing_ids = sorted(set(request.item_ids) - found_ids) if missing_ids: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail={"message": "Some AI variants were not found", "ids": missing_ids}, ) for item in items: ensure_website_scope_matches(website_id, item.website_id) item.variant_status = request.status item.review_notes = request.review_notes item.reviewed_at = datetime.now(timezone.utc) item.reviewed_by = auth.wp_user_id or auth.role await db.commit() return {"updated": len(items), "status": request.status, "item_ids": [item.id for item in items]} @router.get( "/templates", summary="Get question templates", description="Retrieve basis items (templates) for AI generation.", ) async def admin_get_templates( db: AsyncSession = Depends(get_db), auth: AuthContext = Depends(get_auth_context), ) -> Dict[str, Any]: """Retrieve basis items (level=sedang, not AI generated).""" website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) from app.models.item import Item query = ( select(Item) .options(selectinload(Item.variants)) .where(Item.level == "sedang", Item.generated_by != "ai") .order_by(Item.updated_at.desc(), Item.id.desc()) .limit(200) ) if website_id is not None: query = query.where(Item.website_id == website_id) result = await db.execute(query) items = result.scalars().all() return { "items": [ { "id": i.id, "tryout_id": i.tryout_id, "stem_text": i.stem_text if hasattr(i, 'stem_text') else i.stem[:100], "p_value": i.ctt_p, "created_at": i.created_at, "variants_count": len(i.variants) if hasattr(i, 'variants') else 0, # Note: this won't work without a join or lazy loading, let's omit variants_count or do a subquery } for i in items ] }