taboola
add student metrics dashboard, save quiz answer records, save original uploaded files
25cb9ab
Raw History Blame Contribute Delete
12.2 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 fastapi.responses import FileResponse
from pydantic import BaseModel
from .auth import get_current_admin, get_current_user
from .crypto import decrypt
from .database import (
admin_list_sessions,
admin_list_students,
delete_session,
delete_user,
dismiss_issue_report,
get_active_users,
get_answer_grid,
get_assessments_processed,
get_feedback_loop_funnel,
get_parsed_data,
get_practice_quiz_funnel,
get_quiz_results,
get_raw_file,
get_raw_files,
get_schema_version,
get_session,
get_signup_growth,
get_stats,
get_student,
get_student_improvement_summary,
get_student_timeline,
get_teacher_retention,
get_teacher_vocabulary,
list_all_users,
list_open_issue_reports,
set_teacher_vocabulary,
set_user_admin,
wipe_quiz_data,
wipe_question_bank,
list_question_bank,
list_question_bank_tags,
delete_question_bank_row,
)
from .student_credentials import ensure_student_login
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
@router.get("/analytics")
async def dashboard_analytics(_admin=Depends(get_current_admin)):
"""Signup growth over time โ€” admin-only, global (not scoped per teacher)."""
return await get_signup_growth()
@router.get("/analytics/active-users")
async def dashboard_active_users(weeks: int = 12, _admin=Depends(get_current_admin)):
return await get_active_users(weeks=weeks)
@router.get("/analytics/assessments-processed")
async def dashboard_assessments_processed(
months: int = 6, _admin=Depends(get_current_admin)
):
return await get_assessments_processed(months=months)
@router.get("/analytics/practice-quiz-funnel")
async def dashboard_practice_quiz_funnel(_admin=Depends(get_current_admin)):
return await get_practice_quiz_funnel()
@router.get("/analytics/student-improvement")
async def dashboard_student_improvement(_admin=Depends(get_current_admin)):
return await get_student_improvement_summary()
@router.get("/analytics/teacher-retention")
async def dashboard_teacher_retention(_admin=Depends(get_current_admin)):
return await get_teacher_retention()
@router.get("/analytics/feedback-loop")
async def dashboard_feedback_loop(_admin=Depends(get_current_admin)):
return await get_feedback_loop_funnel()
# ---- Issue reports (admin only) ----
@router.get("/issues")
async def dashboard_list_issues(_admin=Depends(get_current_admin)):
return {"issues": await list_open_issue_reports()}
@router.post("/issues/{issue_id}/dismiss")
async def dashboard_dismiss_issue(issue_id: int, _admin=Depends(get_current_admin)):
ok = await dismiss_issue_report(issue_id)
if not ok:
raise HTTPException(
status_code=404, detail="Issue not found or already dismissed"
)
return {"status": "dismissed"}
# ---- 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: 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")
questions = await get_parsed_data(session_id)
grid = await get_answer_grid(session_id)
parsed: dict[str, object] = {"questions": questions}
if grid:
parsed["answer_grid"] = grid
return {
"quiz": quiz,
"parsed_data": parsed,
"results": await get_quiz_results(session_id),
"raw_files": await get_raw_files(session_id),
}
@router.get("/sessions/{session_id}/raw-files/{raw_file_id}")
async def dashboard_raw_file(
session_id: int, raw_file_id: int, user=Depends(get_current_user)
):
"""Serve the original uploaded scan/PDF for a quiz record."""
quiz = await get_session(session_id)
if not quiz or not _owns_quiz(user, quiz):
raise HTTPException(status_code=404, detail="Session not found")
raw_file = await get_raw_file(raw_file_id)
if not raw_file or raw_file["quiz_id"] != session_id:
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(
raw_file["storage_path"],
media_type=raw_file["content_type"] or "application/octet-stream",
filename=raw_file["file_name"],
)
@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"}
@router.delete("/sessions/{session_id}/data")
async def dashboard_wipe_session_data(session_id: int, user=Depends(get_current_user)):
"""Wipe all data for a quiz (results, reports, grids, parsed_data, files) without deleting the quiz."""
quiz = await get_session(session_id)
if not quiz or not _owns_quiz(user, quiz):
raise HTTPException(status_code=404, detail="Session not found")
counts = await wipe_quiz_data(session_id)
return {"status": "wiped", **counts}
@router.delete("/question-bank")
async def dashboard_wipe_question_bank(user=Depends(get_current_user)):
"""Delete question bank rows contributed by the current teacher (or all, for admins)."""
count = await wipe_question_bank(teacher_id=_scope(user))
return {"status": "deleted", "count": count}
_DEV_WIPE_ALLOWED_EMAIL = "melfurever21@gmail.com"
@router.delete("/question-bank/dev-wipe-all")
async def dev_wipe_all_question_bank(user=Depends(get_current_user)):
"""TEMPORARY dev-only tool: wipes the ENTIRE shared question bank (every
teacher's contributions). Restricted to one account by email โ€” the
button is hidden from everyone else in the UI, but that's not real
enforcement on its own, so it's checked here too.
Delete this endpoint and its frontend button once the fresh question set
re-upload is done โ€” it's intentionally a blunt, unscoped wipe and should
not stay in the app long-term. Kept separate from
dashboard_wipe_question_bank above (which is scoped/admin-aware and
meant to be permanent) so removing this later is a clean, contained
deletion.
"""
if user.get("email") != _DEV_WIPE_ALLOWED_EMAIL:
raise HTTPException(status_code=403, detail="Not authorized")
count = await wipe_question_bank(teacher_id=None)
return {"status": "deleted", "count": count}
@router.get("/question-bank")
async def dashboard_list_question_bank(
category: Optional[str] = None,
tag: Optional[str] = None,
search: Optional[str] = None,
limit: int = 50,
offset: int = 0,
user=Depends(get_current_user),
):
"""Browse the shared question bank (the pool practice-quiz generation
draws from โ€” every teacher's contributions, visible to every teacher)."""
rows, total = await list_question_bank(
category, tag, search, limit=limit, offset=offset
)
return {"questions": rows, "total": total}
@router.get("/question-bank/tags")
async def dashboard_question_bank_tags(user=Depends(get_current_user)):
return {"categories": await list_question_bank_tags()}
@router.delete("/question-bank/{question_id}")
async def dashboard_delete_question_bank_row(
question_id: int, user=Depends(get_current_user)
):
"""Remove a single question from the shared bank โ€” any teacher can curate
it, since generation draws from the whole shared pool, not just their own
contributions."""
ok = await delete_question_bank_row(question_id)
if not ok:
raise HTTPException(status_code=404, detail="Question not found")
return {"status": "deleted"}
# ---- Vocabulary (personal, per-teacher โ€” not shared) ----
class VocabWord(BaseModel):
word: str
class SetVocabularyRequest(BaseModel):
words: list[VocabWord]
@router.get("/vocabulary")
async def dashboard_get_vocabulary(user=Depends(get_current_user)):
return {"words": await get_teacher_vocabulary(user["id"])}
@router.put("/vocabulary")
async def dashboard_set_vocabulary(
body: SetVocabularyRequest, user=Depends(get_current_user)
):
words = [w.model_dump() for w in body.words]
saved = await set_teacher_vocabulary(user["id"], words)
return {"words": saved}
# ---- Students (scoped) ----
async def _ensure_login(student: dict) -> dict:
"""Auto-issue a login the first time a student shows up here without
one (see student_credentials.ensure_student_login). Swaps the stored
ciphertext for the plaintext password so ๆˆ‘็š„่ณ‡ๆ–™ shows credentials on an
ongoing basis, not just once at issuance."""
student, new_password = await ensure_student_login(student)
if new_password is not None:
student.pop("password_encrypted", None)
student["password"] = new_password
return student
encrypted = student.pop("password_encrypted", None)
student["password"] = decrypt(encrypted) if encrypted else None
return student
@router.get("/students")
async def dashboard_students(user=Depends(get_current_user)):
students = await admin_list_students(_scope(user))
return {"students": [await _ensure_login(s) for s in students]}
@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": await _ensure_login(student),
"timeline": await get_student_timeline(student_id),
}