1078 lines
39 KiB
Python
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
|
|
]
|
|
}
|