Spaces:
Sleeping
Sleeping
chih.yikuan
✨ Add database management: Supabase persistence, teacher profiles, admin dashboard
9f97e28 | """Data-access layer for ClassLens, backed by SQLAlchemy async (Supabase Postgres). | |
| Public function names/signatures are kept stable for existing callers: | |
| - "session" in the API == a row in the ``quizzes`` table (a quiz/exam session) | |
| - "user" == a row in the ``teachers`` table | |
| New capabilities (students as first-class entities, per-student results, and | |
| analytics) are added alongside the legacy CRUD helpers. | |
| """ | |
| from __future__ import annotations | |
| import re | |
| import unicodedata | |
| from datetime import datetime, timezone | |
| from typing import Optional | |
| from sqlalchemy import delete, func, select, update | |
| from .config import get_settings | |
| from .db import get_engine, get_sessionmaker | |
| from .models import ( | |
| Base, | |
| ParsedData, | |
| Prompt, | |
| Quiz, | |
| RawFile, | |
| Report, | |
| Student, | |
| StudentResult, | |
| Teacher, | |
| ) | |
| ANSWER_GRID_DATA_TYPE = "answer_grid" | |
| def _now() -> datetime: | |
| return datetime.now(timezone.utc) | |
| def _row(obj) -> dict: | |
| """Serialize an ORM object to a plain dict of its columns.""" | |
| return {c.key: getattr(obj, c.key) for c in obj.__table__.columns} | |
| def normalize_name(name: str) -> str: | |
| """Canonical key for matching the same student across quizzes.""" | |
| s = unicodedata.normalize("NFKC", (name or "").strip()).casefold() | |
| return re.sub(r"\s+", " ", s) | |
| # ============================================================================= | |
| # Schema bootstrap | |
| # ============================================================================= | |
| async def init_database(): | |
| """Ensure schema exists and bootstrap admins. | |
| On Supabase, tables are managed by Alembic migrations; here we only create | |
| tables for the SQLite dev/test fallback. Admin bootstrap runs for both. | |
| """ | |
| settings = get_settings() | |
| if settings.database_provider != "supabase": | |
| async with get_engine().begin() as conn: | |
| await conn.run_sync(Base.metadata.create_all) | |
| await _bootstrap_admins() | |
| async def _bootstrap_admins(): | |
| settings = get_settings() | |
| emails = [e.strip().lower() for e in (settings.admin_emails or "").split(",") if e.strip()] | |
| if not emails: | |
| return | |
| async with get_sessionmaker()() as db: | |
| await db.execute( | |
| update(Teacher).where(func.lower(Teacher.email).in_(emails)).values(is_admin=True) | |
| ) | |
| await db.commit() | |
| # ============================================================================= | |
| # Teachers (users) | |
| # ============================================================================= | |
| async def create_user( | |
| email: str, | |
| password_hash: str, | |
| display_name: str = "", | |
| full_name: str = "", | |
| school: str = "", | |
| ) -> int: | |
| async with get_sessionmaker()() as db: | |
| teacher = Teacher( | |
| email=email, | |
| password_hash=password_hash, | |
| display_name=display_name or email.split("@")[0], | |
| full_name=full_name, | |
| school=school, | |
| ) | |
| db.add(teacher) | |
| await db.commit() | |
| await db.refresh(teacher) | |
| return teacher.id | |
| async def get_user_by_email(email: str) -> Optional[dict]: | |
| async with get_sessionmaker()() as db: | |
| res = await db.execute(select(Teacher).where(Teacher.email == email)) | |
| teacher = res.scalar_one_or_none() | |
| return _row(teacher) if teacher else None | |
| async def get_user_by_id(user_id: int) -> Optional[dict]: | |
| async with get_sessionmaker()() as db: | |
| teacher = await db.get(Teacher, user_id) | |
| return _row(teacher) if teacher else None | |
| async def update_last_login(user_id: int): | |
| async with get_sessionmaker()() as db: | |
| await db.execute( | |
| update(Teacher).where(Teacher.id == user_id).values(last_login=_now()) | |
| ) | |
| await db.commit() | |
| async def set_user_admin(user_id: int, is_admin: bool): | |
| async with get_sessionmaker()() as db: | |
| await db.execute( | |
| update(Teacher).where(Teacher.id == user_id).values(is_admin=is_admin) | |
| ) | |
| await db.commit() | |
| async def list_all_users() -> list[dict]: | |
| """Admin: all teachers (without password hashes), with per-teacher counts.""" | |
| async with get_sessionmaker()() as db: | |
| res = await db.execute(select(Teacher).order_by(Teacher.created_at.desc())) | |
| teachers = res.scalars().all() | |
| out = [] | |
| for t in teachers: | |
| d = _row(t) | |
| d.pop("password_hash", None) | |
| d["quiz_count"] = ( | |
| await db.execute(select(func.count(Quiz.id)).where(Quiz.teacher_id == t.id)) | |
| ).scalar_one() | |
| d["student_count"] = ( | |
| await db.execute(select(func.count(Student.id)).where(Student.teacher_id == t.id)) | |
| ).scalar_one() | |
| out.append(d) | |
| return out | |
| async def delete_user(user_id: int): | |
| async with get_sessionmaker()() as db: | |
| await db.execute(delete(Teacher).where(Teacher.id == user_id)) | |
| await db.commit() | |
| # ============================================================================= | |
| # Quizzes (sessions) | |
| # ============================================================================= | |
| async def create_session(user_id: int, title: str = "Untitled Session") -> int: | |
| async with get_sessionmaker()() as db: | |
| quiz = Quiz(teacher_id=user_id, title=title, status="draft") | |
| db.add(quiz) | |
| await db.commit() | |
| await db.refresh(quiz) | |
| return quiz.id | |
| async def get_sessions(user_id: int) -> list[dict]: | |
| async with get_sessionmaker()() as db: | |
| res = await db.execute( | |
| select(Quiz).where(Quiz.teacher_id == user_id).order_by(Quiz.created_at.desc()) | |
| ) | |
| return [_row(q) for q in res.scalars().all()] | |
| async def get_session(session_id: int) -> Optional[dict]: | |
| async with get_sessionmaker()() as db: | |
| quiz = await db.get(Quiz, session_id) | |
| if not quiz: | |
| return None | |
| d = _row(quiz) | |
| d["user_id"] = quiz.teacher_id # back-compat key for existing callers | |
| return d | |
| async def update_session_status(session_id: int, status: str): | |
| async with get_sessionmaker()() as db: | |
| await db.execute( | |
| update(Quiz).where(Quiz.id == session_id).values(status=status, updated_at=_now()) | |
| ) | |
| await db.commit() | |
| async def update_session_meta(session_id: int, title: Optional[str] = None, subject: Optional[str] = None): | |
| values: dict = {"updated_at": _now()} | |
| if title is not None: | |
| values["title"] = title | |
| if subject is not None: | |
| values["subject"] = subject | |
| async with get_sessionmaker()() as db: | |
| await db.execute(update(Quiz).where(Quiz.id == session_id).values(**values)) | |
| await db.commit() | |
| async def delete_session(session_id: int): | |
| async with get_sessionmaker()() as db: | |
| await db.execute(delete(Quiz).where(Quiz.id == session_id)) | |
| await db.commit() | |
| # ============================================================================= | |
| # Parsed data | |
| # ============================================================================= | |
| async def save_parsed_data( | |
| session_id: int, data_type: str, file_name: str, raw_text: str, structured_data: dict | |
| ) -> int: | |
| async with get_sessionmaker()() as db: | |
| row = ParsedData( | |
| quiz_id=session_id, | |
| data_type=data_type, | |
| file_name=file_name or "", | |
| raw_text=raw_text or "", | |
| structured_data=structured_data, | |
| ) | |
| db.add(row) | |
| await db.commit() | |
| await db.refresh(row) | |
| return row.id | |
| async def get_parsed_data(session_id: int) -> list[dict]: | |
| async with get_sessionmaker()() as db: | |
| res = await db.execute( | |
| select(ParsedData) | |
| .where(ParsedData.quiz_id == session_id) | |
| .order_by(ParsedData.data_type, ParsedData.created_at) | |
| ) | |
| return [_row(p) for p in res.scalars().all()] | |
| async def delete_parsed_data(session_id: int, data_type: Optional[str] = None): | |
| async with get_sessionmaker()() as db: | |
| stmt = delete(ParsedData).where(ParsedData.quiz_id == session_id) | |
| if data_type: | |
| stmt = stmt.where(ParsedData.data_type == data_type) | |
| await db.execute(stmt) | |
| await db.commit() | |
| # ---- Answer grid (stored as a parsed_data row) ---- | |
| async def save_answer_grid(session_id: int, grid_dict: dict) -> int: | |
| await delete_parsed_data(session_id, ANSWER_GRID_DATA_TYPE) | |
| return await save_parsed_data( | |
| session_id=session_id, | |
| data_type=ANSWER_GRID_DATA_TYPE, | |
| file_name="", | |
| raw_text="", | |
| structured_data=grid_dict, | |
| ) | |
| async def get_answer_grid(session_id: int) -> Optional[dict]: | |
| async with get_sessionmaker()() as db: | |
| res = await db.execute( | |
| select(ParsedData.structured_data) | |
| .where(ParsedData.quiz_id == session_id) | |
| .where(ParsedData.data_type == ANSWER_GRID_DATA_TYPE) | |
| .order_by(ParsedData.created_at.desc()) | |
| .limit(1) | |
| ) | |
| row = res.scalar_one_or_none() | |
| return row if row else None | |
| # ============================================================================= | |
| # Raw files (metadata; bytes live in Supabase Storage) | |
| # ============================================================================= | |
| async def save_raw_file( | |
| session_id: int, | |
| data_type: str, | |
| file_name: str, | |
| content_type: str, | |
| size_bytes: int, | |
| storage_path: str, | |
| ) -> int: | |
| async with get_sessionmaker()() as db: | |
| row = RawFile( | |
| quiz_id=session_id, | |
| data_type=data_type, | |
| file_name=file_name, | |
| content_type=content_type, | |
| size_bytes=size_bytes, | |
| storage_path=storage_path, | |
| ) | |
| db.add(row) | |
| await db.commit() | |
| await db.refresh(row) | |
| return row.id | |
| async def get_raw_files(session_id: int) -> list[dict]: | |
| async with get_sessionmaker()() as db: | |
| res = await db.execute( | |
| select(RawFile).where(RawFile.quiz_id == session_id).order_by(RawFile.created_at) | |
| ) | |
| return [_row(f) for f in res.scalars().all()] | |
| # ============================================================================= | |
| # Students (first-class, matched across quizzes by normalized name) | |
| # ============================================================================= | |
| async def match_or_create_student( | |
| teacher_id: int, name: str, external_id: Optional[str] = None | |
| ) -> int: | |
| """Return the student id for this teacher, creating the row if new.""" | |
| norm = normalize_name(name) | |
| async with get_sessionmaker()() as db: | |
| res = await db.execute( | |
| select(Student) | |
| .where(Student.teacher_id == teacher_id) | |
| .where(Student.normalized_name == norm) | |
| ) | |
| student = res.scalar_one_or_none() | |
| if student: | |
| if external_id and not student.external_id: | |
| student.external_id = external_id | |
| await db.commit() | |
| return student.id | |
| student = Student( | |
| teacher_id=teacher_id, name=name, normalized_name=norm, external_id=external_id | |
| ) | |
| db.add(student) | |
| await db.commit() | |
| await db.refresh(student) | |
| return student.id | |
| async def get_students(teacher_id: int) -> list[dict]: | |
| async with get_sessionmaker()() as db: | |
| res = await db.execute( | |
| select(Student).where(Student.teacher_id == teacher_id).order_by(Student.name) | |
| ) | |
| return [_row(s) for s in res.scalars().all()] | |
| async def get_student(student_id: int) -> Optional[dict]: | |
| async with get_sessionmaker()() as db: | |
| student = await db.get(Student, student_id) | |
| return _row(student) if student else None | |
| async def save_student_result( | |
| quiz_id: int, | |
| student_id: int, | |
| answers: dict, | |
| total_questions: int, | |
| correct_count: int, | |
| score: float, | |
| ) -> int: | |
| """Upsert one student's result for a quiz (unique per quiz+student).""" | |
| async with get_sessionmaker()() as db: | |
| res = await db.execute( | |
| select(StudentResult) | |
| .where(StudentResult.quiz_id == quiz_id) | |
| .where(StudentResult.student_id == student_id) | |
| ) | |
| existing = res.scalar_one_or_none() | |
| if existing: | |
| existing.answers = answers | |
| existing.total_questions = total_questions | |
| existing.correct_count = correct_count | |
| existing.score = score | |
| await db.commit() | |
| return existing.id | |
| row = StudentResult( | |
| quiz_id=quiz_id, | |
| student_id=student_id, | |
| answers=answers, | |
| total_questions=total_questions, | |
| correct_count=correct_count, | |
| score=score, | |
| ) | |
| db.add(row) | |
| await db.commit() | |
| await db.refresh(row) | |
| return row.id | |
| async def get_student_timeline(student_id: int) -> list[dict]: | |
| """One student's results across all quizzes, oldest first — the trajectory.""" | |
| async with get_sessionmaker()() as db: | |
| res = await db.execute( | |
| select(StudentResult, Quiz.title, Quiz.subject, Quiz.created_at) | |
| .join(Quiz, StudentResult.quiz_id == Quiz.id) | |
| .where(StudentResult.student_id == student_id) | |
| .order_by(Quiz.created_at) | |
| ) | |
| out = [] | |
| for sr, title, subject, created in res.all(): | |
| d = _row(sr) | |
| d["quiz_title"] = title | |
| d["quiz_subject"] = subject | |
| d["quiz_created_at"] = created | |
| out.append(d) | |
| return out | |
| # ============================================================================= | |
| # Prompts | |
| # ============================================================================= | |
| async def save_prompt(user_id: int, name: str, content: str, is_default: bool = False) -> int: | |
| async with get_sessionmaker()() as db: | |
| row = Prompt(teacher_id=user_id, name=name, content=content, is_default=is_default) | |
| db.add(row) | |
| await db.commit() | |
| await db.refresh(row) | |
| return row.id | |
| async def get_prompts(user_id: int) -> list[dict]: | |
| async with get_sessionmaker()() as db: | |
| res = await db.execute( | |
| select(Prompt) | |
| .where((Prompt.teacher_id == user_id) | (Prompt.is_default.is_(True))) | |
| .order_by(Prompt.is_default.desc(), Prompt.updated_at.desc()) | |
| ) | |
| return [_row(p) for p in res.scalars().all()] | |
| async def get_all_prompts() -> list[dict]: | |
| async with get_sessionmaker()() as db: | |
| res = await db.execute( | |
| select(Prompt, Teacher.email) | |
| .join(Teacher, Prompt.teacher_id == Teacher.id) | |
| .order_by(Prompt.updated_at.desc()) | |
| ) | |
| out = [] | |
| for p, email in res.all(): | |
| d = _row(p) | |
| d["author_email"] = email | |
| out.append(d) | |
| return out | |
| async def get_prompt(prompt_id: int) -> Optional[dict]: | |
| async with get_sessionmaker()() as db: | |
| p = await db.get(Prompt, prompt_id) | |
| if not p: | |
| return None | |
| d = _row(p) | |
| d["user_id"] = p.teacher_id # back-compat key | |
| return d | |
| async def update_prompt(prompt_id: int, name: str, content: str): | |
| async with get_sessionmaker()() as db: | |
| await db.execute( | |
| update(Prompt) | |
| .where(Prompt.id == prompt_id) | |
| .values(name=name, content=content, updated_at=_now()) | |
| ) | |
| await db.commit() | |
| async def delete_prompt(prompt_id: int): | |
| async with get_sessionmaker()() as db: | |
| await db.execute(delete(Prompt).where(Prompt.id == prompt_id)) | |
| await db.commit() | |
| # ============================================================================= | |
| # Reports | |
| # ============================================================================= | |
| async def save_report( | |
| session_id: int, | |
| prompt_id: Optional[int], | |
| html_content: str, | |
| student_id: Optional[int] = None, | |
| model: str = "", | |
| ) -> int: | |
| async with get_sessionmaker()() as db: | |
| row = Report( | |
| quiz_id=session_id, | |
| prompt_id=prompt_id, | |
| student_id=student_id, | |
| html_content=html_content, | |
| model=model, | |
| ) | |
| db.add(row) | |
| await db.commit() | |
| await db.refresh(row) | |
| return row.id | |
| async def get_reports(session_id: int) -> list[dict]: | |
| async with get_sessionmaker()() as db: | |
| res = await db.execute( | |
| select(Report).where(Report.quiz_id == session_id).order_by(Report.created_at.desc()) | |
| ) | |
| return [_row(r) for r in res.scalars().all()] | |
| async def get_report(report_id: int) -> Optional[dict]: | |
| async with get_sessionmaker()() as db: | |
| r = await db.get(Report, report_id) | |
| return _row(r) if r else None | |
| # ============================================================================= | |
| # Analytics (admin dashboard) | |
| # ============================================================================= | |
| async def get_stats() -> dict: | |
| async with get_sessionmaker()() as db: | |
| async def count(model): | |
| return (await db.execute(select(func.count(model.id)))).scalar_one() | |
| return { | |
| "teachers": await count(Teacher), | |
| "students": await count(Student), | |
| "quizzes": await count(Quiz), | |
| "reports": await count(Report), | |
| "student_results": await count(StudentResult), | |
| "prompts": await count(Prompt), | |
| } | |
| async def admin_list_students(limit: int = 500) -> list[dict]: | |
| """All students across teachers, with quiz count + average score (admin).""" | |
| async with get_sessionmaker()() as db: | |
| res = await db.execute( | |
| select(Student, Teacher.email).join(Teacher, Student.teacher_id == Teacher.id).order_by(Student.name).limit(limit) | |
| ) | |
| out = [] | |
| for s, email in res.all(): | |
| d = _row(s) | |
| d["teacher_email"] = email | |
| agg = ( | |
| await db.execute( | |
| select(func.count(StudentResult.id), func.avg(StudentResult.score)).where( | |
| StudentResult.student_id == s.id | |
| ) | |
| ) | |
| ).one() | |
| d["result_count"] = agg[0] | |
| d["avg_score"] = round(float(agg[1]), 1) if agg[1] is not None else None | |
| out.append(d) | |
| return out | |
| async def get_schema_version() -> Optional[str]: | |
| """Current Alembic migration revision applied to the DB.""" | |
| from sqlalchemy import text | |
| from .db import active_schema | |
| schema = active_schema() | |
| table = f'"{schema}".alembic_version' if schema else "alembic_version" | |
| async with get_sessionmaker()() as db: | |
| try: | |
| res = await db.execute(text(f"SELECT version_num FROM {table} LIMIT 1")) | |
| row = res.first() | |
| return row[0] if row else None | |
| except Exception: | |
| return None | |
| async def admin_list_sessions(limit: int = 200) -> list[dict]: | |
| """All quizzes across teachers, with author email + counts (admin).""" | |
| async with get_sessionmaker()() as db: | |
| res = await db.execute( | |
| select(Quiz, Teacher.email) | |
| .join(Teacher, Quiz.teacher_id == Teacher.id) | |
| .order_by(Quiz.created_at.desc()) | |
| .limit(limit) | |
| ) | |
| out = [] | |
| for q, email in res.all(): | |
| d = _row(q) | |
| d["teacher_email"] = email | |
| d["report_count"] = ( | |
| await db.execute(select(func.count(Report.id)).where(Report.quiz_id == q.id)) | |
| ).scalar_one() | |
| out.append(d) | |
| return out | |