ClassLens / chatkit /backend /app /admin.py
chih.yikuan
✨ Dashboard for all teachers (own-data scoped) + fix report IDOR
3ee2df6
Raw
History Blame
4.63 kB
"""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)}