"""Dashboard API. Two access levels share these routes: - Admins (is_admin) see ALL teachers' data and can manage users. - Regular teachers see ONLY their own data (their quizzes/students/results) and cannot reach user-management or other teachers' records. Scoping is enforced server-side: list endpoints filter by the caller's teacher id, and detail endpoints verify ownership before returning anything. """ from __future__ import annotations from typing import Optional from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from .auth import get_current_admin, get_current_user from .database import ( admin_list_sessions, admin_list_students, delete_session, delete_user, get_parsed_data, get_quiz_results, get_raw_files, get_schema_version, get_session, get_stats, get_student, get_student_timeline, list_all_users, set_user_admin, ) from .storage import create_signed_url router = APIRouter(prefix="/api/admin", tags=["admin"]) def _scope(user: dict) -> Optional[int]: """None for admins (see everything), else the teacher id to scope to.""" return None if user.get("is_admin") else user["id"] def _owns_quiz(user: dict, quiz: dict) -> bool: return user.get("is_admin") or quiz.get("teacher_id") == user["id"] def _owns_student(user: dict, student: dict) -> bool: return user.get("is_admin") or student.get("teacher_id") == user["id"] @router.get("/stats") async def dashboard_stats(user=Depends(get_current_user)): stats = await get_stats(_scope(user)) stats["is_admin"] = bool(user.get("is_admin")) if user.get("is_admin"): stats["schema_version"] = await get_schema_version() return stats # ---- User management (admin only) ---- @router.get("/users") async def admin_users(_admin=Depends(get_current_admin)): return {"users": await list_all_users()} class SetAdminRequest(BaseModel): is_admin: bool @router.put("/users/{user_id}/admin") async def admin_set_admin(user_id: int, body: SetAdminRequest, admin=Depends(get_current_admin)): if user_id == admin["id"] and not body.is_admin: raise HTTPException(status_code=400, detail="You cannot remove your own admin access") await set_user_admin(user_id, body.is_admin) return {"status": "ok", "user_id": user_id, "is_admin": body.is_admin} @router.delete("/users/{user_id}") async def admin_delete_user(user_id: int, admin=Depends(get_current_admin)): if user_id == admin["id"]: raise HTTPException(status_code=400, detail="You cannot delete your own account here") await delete_user(user_id) return {"status": "deleted"} # ---- Sessions / quizzes (scoped) ---- @router.get("/sessions") async def dashboard_sessions(user=Depends(get_current_user)): return {"sessions": await admin_list_sessions(_scope(user))} @router.get("/sessions/{session_id}") async def dashboard_session_detail(session_id: int, user=Depends(get_current_user)): """Full quiz detail: raw files (signed URLs), structured parsed data, results.""" quiz = await get_session(session_id) if not quiz or not _owns_quiz(user, quiz): raise HTTPException(status_code=404, detail="Session not found") parsed = {} for item in await get_parsed_data(session_id): parsed[item["data_type"]] = item["structured_data"] raw_files = [] for f in await get_raw_files(session_id): f["download_url"] = await create_signed_url(f.get("storage_path", "")) raw_files.append(f) return { "quiz": quiz, "raw_files": raw_files, "parsed_data": parsed, "results": await get_quiz_results(session_id), } @router.delete("/sessions/{session_id}") async def dashboard_delete_session(session_id: int, user=Depends(get_current_user)): quiz = await get_session(session_id) if not quiz or not _owns_quiz(user, quiz): raise HTTPException(status_code=404, detail="Session not found") await delete_session(session_id) return {"status": "deleted"} # ---- Students (scoped) ---- @router.get("/students") async def dashboard_students(user=Depends(get_current_user)): return {"students": await admin_list_students(_scope(user))} @router.get("/students/{student_id}") async def dashboard_student_detail(student_id: int, user=Depends(get_current_user)): student = await get_student(student_id) if not student or not _owns_student(user, student): raise HTTPException(status_code=404, detail="Student not found") return {"student": student, "timeline": await get_student_timeline(student_id)}