Files
yellow-bank-soal/backend/app/routers/admin.py
2026-06-20 01:43:39 +07:00

1078 lines
39 KiB
Python

"""
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
]
}