Spaces:
Running
Running
| """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 random | |
| import re | |
| import secrets | |
| import unicodedata | |
| from datetime import datetime, date, timezone | |
| from typing import Optional | |
| from sqlalchemy import Date, cast, delete, func, select, update | |
| from sqlalchemy.dialects.postgresql import insert as pg_insert | |
| from sqlalchemy.orm.attributes import flag_modified | |
| from .config import get_settings | |
| from .db import get_engine, get_sessionmaker | |
| from .models import ( | |
| AnswerGrid as AnswerGridRecord, | |
| Base, | |
| ParsedData, | |
| PracticeQuiz, | |
| Prompt, | |
| Quiz, | |
| QuestionBank, | |
| RawFile, | |
| Report, | |
| Student, | |
| StudentResult, | |
| Teacher, | |
| ) | |
| 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.flush() | |
| teacher_id = teacher.id | |
| await db.commit() | |
| 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 = "", quiz_date: Optional[date] = None) -> int: | |
| async with get_sessionmaker()() as db: | |
| if not title: | |
| # Number sequentially by quizzes the teacher actually finished and | |
| # saved — not every draft session ever created (a fresh draft is | |
| # created every time the upload flow is opened, so counting those | |
| # inflated this quickly, e.g. "Quiz 36" after just a few real quizzes). | |
| count = (await db.execute( | |
| select(func.count(Quiz.id)).where( | |
| Quiz.teacher_id == user_id, Quiz.status == "saved" | |
| ) | |
| )).scalar_one() | |
| title = f"Quiz {count + 1}" | |
| quiz = Quiz(teacher_id=user_id, title=title, status="draft", quiz_date=quiz_date) | |
| db.add(quiz) | |
| await db.flush() | |
| quiz_id = quiz.id | |
| await db.commit() | |
| 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, quiz_date: Optional[date] = None): | |
| values: dict = {"updated_at": _now()} | |
| if title is not None: | |
| values["title"] = title | |
| if subject is not None: | |
| values["subject"] = subject | |
| if quiz_date is not None: | |
| values["quiz_date"] = quiz_date | |
| 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() | |
| async def delete_all_sessions(teacher_id: Optional[int] = None) -> int: | |
| """Delete every quiz for a teacher (or all teachers if None), cascading to every | |
| piece of derived data: results, reports, weaknesses/recommendations, answer grids, | |
| parsed data, and raw files. Returns the number of quizzes deleted.""" | |
| async with get_sessionmaker()() as db: | |
| stmt = delete(Quiz) | |
| if teacher_id is not None: | |
| stmt = stmt.where(Quiz.teacher_id == teacher_id) | |
| result = await db.execute(stmt) | |
| await db.commit() | |
| return result.rowcount or 0 | |
| # ============================================================================= | |
| # Parsed data | |
| # ============================================================================= | |
| async def save_parsed_data( | |
| session_id: int, | |
| question_num: int, | |
| question_str: str, | |
| answer: str, | |
| main_category: str, | |
| tags: list, | |
| options: Optional[list] = None, | |
| ) -> None: | |
| """Upsert a ParsedData row by (quiz_id, question_num). | |
| Uses ON CONFLICT instead of a plain insert so concurrent/retried uploads | |
| for the same quiz can't create duplicate rows per question number. | |
| """ | |
| async with get_sessionmaker()() as db: | |
| stmt = pg_insert(ParsedData).values( | |
| quiz_id=session_id, | |
| question_num=question_num, | |
| question_str=question_str, | |
| options=options or [], | |
| answer=answer, | |
| main_category=main_category, | |
| tags=tags, | |
| ) | |
| stmt = stmt.on_conflict_do_update( | |
| constraint="uq_parsed_data_quiz_question", | |
| set_={ | |
| "question_str": stmt.excluded.question_str, | |
| "options": stmt.excluded.options, | |
| "answer": stmt.excluded.answer, | |
| "main_category": stmt.excluded.main_category, | |
| "tags": stmt.excluded.tags, | |
| }, | |
| ) | |
| await db.execute(stmt) | |
| await db.commit() | |
| 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.question_num) | |
| ) | |
| return [_row(p) for p in res.scalars().all()] | |
| async def delete_parsed_data(session_id: int, data_type: str = ""): | |
| """Delete all ParsedData rows for a session. data_type accepted but unused (no such column).""" | |
| async with get_sessionmaker()() as db: | |
| await db.execute(delete(ParsedData).where(ParsedData.quiz_id == session_id)) | |
| await db.commit() | |
| async def update_parsed_data_categories( | |
| session_id: int, categories: list[dict] | |
| ) -> None: | |
| """Bulk-update main_category + tags for parsed questions. | |
| `categories` is a list of {question_num, main_category, tags}. | |
| """ | |
| async with get_sessionmaker()() as db: | |
| for entry in categories: | |
| await db.execute( | |
| update(ParsedData) | |
| .where(ParsedData.quiz_id == session_id) | |
| .where(ParsedData.question_num == entry["question_num"]) | |
| .values( | |
| main_category=entry.get("main_category", ""), | |
| tags=entry.get("tags", []), | |
| ) | |
| ) | |
| await db.commit() | |
| # ============================================================================= | |
| # Question bank (shared across all teachers) | |
| # ============================================================================= | |
| async def save_question_bank_batch( | |
| quiz_id: int, | |
| teacher_id: int, | |
| questions: list[dict], | |
| ) -> int: | |
| """Insert categorized questions into the shared question bank. | |
| `questions` is a list of {question_text, answer, main_category, tags}. | |
| Skips questions with empty question_text and exact-text duplicates. | |
| Returns the number of new questions inserted. | |
| """ | |
| texts = [q["question_text"] for q in questions if q.get("question_text")] | |
| if not texts: | |
| return 0 | |
| async with get_sessionmaker()() as db: | |
| result = await db.execute( | |
| select(QuestionBank.question_text).where(QuestionBank.question_text.in_(texts)) | |
| ) | |
| existing = {row[0] for row in result} | |
| inserted = 0 | |
| for q in questions: | |
| if not q.get("question_text") or q["question_text"] in existing: | |
| continue | |
| db.add(QuestionBank( | |
| quiz_id=quiz_id, | |
| teacher_id=teacher_id, | |
| question_text=q["question_text"], | |
| answer=q.get("answer", ""), | |
| main_category=q.get("main_category", ""), | |
| tags=q.get("tags", []), | |
| )) | |
| inserted += 1 | |
| await db.commit() | |
| return inserted | |
| async def query_question_bank( | |
| main_category: str, | |
| tags: list[str], | |
| limit: int = 30, | |
| ) -> list[dict]: | |
| """Return questions matching a category (and optionally any of the given tags), | |
| randomly sampled from the full matching set. | |
| If tags are specified, returns questions that contain AT LEAST ONE matching tag. | |
| Falls back to category-only matches when no tag-matched questions are found. | |
| """ | |
| async with get_sessionmaker()() as db: | |
| # Fetch every question in the category — capping this to a | |
| # created_at-ordered window before filtering by tag meant older | |
| # (but still tag-matching) questions were never even considered, and | |
| # the same newest slice got returned on every call regardless of any | |
| # later shuffle. Random sampling needs to see the whole pool first. | |
| res = await db.execute( | |
| select(QuestionBank).where(QuestionBank.main_category == main_category) | |
| ) | |
| rows = [_row(r) for r in res.scalars().all()] | |
| if tags: | |
| tag_set = set(tags) | |
| matched = [r for r in rows if tag_set & set(r.get("tags") or [])] | |
| if matched: | |
| random.shuffle(matched) | |
| return matched[:limit] | |
| random.shuffle(rows) | |
| return rows[:limit] | |
| # ---- Answer grid (own table — one confirmed grid per quiz) ---- | |
| async def save_answer_grid(session_id: int, grid_dict: dict) -> int: | |
| async with get_sessionmaker()() as db: | |
| res = await db.execute( | |
| select(AnswerGridRecord).where(AnswerGridRecord.quiz_id == session_id) | |
| ) | |
| existing = res.scalar_one_or_none() | |
| if existing: | |
| existing.grid_json = grid_dict | |
| flag_modified(existing, "grid_json") | |
| await db.commit() | |
| return existing.id | |
| row = AnswerGridRecord(quiz_id=session_id, grid_json=grid_dict) | |
| db.add(row) | |
| await db.commit() | |
| return 0 | |
| async def get_answer_grid(session_id: int) -> Optional[dict]: | |
| async with get_sessionmaker()() as db: | |
| res = await db.execute( | |
| select(AnswerGridRecord.grid_json).where(AnswerGridRecord.quiz_id == session_id) | |
| ) | |
| row = res.scalar_one_or_none() | |
| return row if row else None | |
| async def delete_answer_grid(session_id: int): | |
| async with get_sessionmaker()() as db: | |
| await db.execute(delete(AnswerGridRecord).where(AnswerGridRecord.quiz_id == session_id)) | |
| await db.commit() | |
| # ============================================================================= | |
| # 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.flush() | |
| row_id = row.id | |
| await db.commit() | |
| 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 save_grid_results_batch( | |
| teacher_id: int, quiz_id: int, students: list[tuple[str, float]] | |
| ) -> list[dict]: | |
| """Upsert a Student + StudentResult row for every student in a grid. | |
| Runs in a single DB session instead of one per student (as | |
| match_or_create_student + save_student_result do when called in a loop) — | |
| this is called on every answer-grid save and every save-to-db, so for a | |
| class of 30 it turned ~60 sequential connection round-trips into 1. | |
| Each student is wrapped in its own savepoint so one bad row (a data quirk, | |
| a constraint violation) is skipped without rolling back everyone else in | |
| the same batch — matching the old per-student try/except isolation. | |
| Never overwrites an existing result's weaknesses/study_recommendations. | |
| """ | |
| saved: list[dict] = [] | |
| async with get_sessionmaker()() as db: | |
| for name, score in students: | |
| try: | |
| async with db.begin_nested(): | |
| norm = normalize_name(name) | |
| res = await db.execute( | |
| select(Student) | |
| .where(Student.teacher_id == teacher_id) | |
| .where(Student.normalized_name == norm) | |
| ) | |
| student = res.scalar_one_or_none() | |
| if not student: | |
| student = Student(teacher_id=teacher_id, name=name, normalized_name=norm) | |
| db.add(student) | |
| await db.flush() | |
| student_id = student.id | |
| res2 = await db.execute( | |
| select(StudentResult) | |
| .where(StudentResult.quiz_id == quiz_id) | |
| .where(StudentResult.student_id == student_id) | |
| ) | |
| existing = res2.scalar_one_or_none() | |
| if existing: | |
| existing.score = score | |
| else: | |
| db.add(StudentResult(quiz_id=quiz_id, student_id=student_id, score=score)) | |
| saved.append({"student_id": student_id, "name": name, "score": score}) | |
| except Exception: | |
| continue # isolate this student's failure from the rest of the batch | |
| await db.commit() | |
| return saved | |
| 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.flush() | |
| student_id = student.id | |
| await db.commit() | |
| return student_id | |
| async def update_student_email(student_id: int, teacher_id: int, email: str) -> bool: | |
| """Set a student's email (for practice-quiz delivery). Returns False if | |
| the student doesn't exist or isn't owned by this teacher. | |
| """ | |
| async with get_sessionmaker()() as db: | |
| res = await db.execute( | |
| select(Student) | |
| .where(Student.id == student_id) | |
| .where(Student.teacher_id == teacher_id) | |
| ) | |
| student = res.scalar_one_or_none() | |
| if not student: | |
| return False | |
| student.email = email.strip() or None | |
| await db.commit() | |
| return True | |
| 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_ai_summary( | |
| student_id: int, summary: str, focus_areas: str, study_recommendations: str | |
| ) -> None: | |
| """Cache the cross-quiz AI synthesis on the student row. | |
| Refreshed whenever a new single-quiz report is generated for this | |
| student, so the dashboard can read it instantly instead of calling the | |
| LLM on every view. | |
| """ | |
| async with get_sessionmaker()() as db: | |
| await db.execute( | |
| update(Student) | |
| .where(Student.id == student_id) | |
| .values( | |
| ai_summary=summary, | |
| ai_focus_areas=focus_areas, | |
| ai_study_recommendations=study_recommendations, | |
| ai_summary_updated_at=_now(), | |
| ) | |
| ) | |
| await db.commit() | |
| async def save_student_result( | |
| quiz_id: int, | |
| student_id: int, | |
| score: float, | |
| weaknesses: Optional[str] = None, | |
| study_recommendations: Optional[str] = None, | |
| ) -> 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.score = score | |
| if weaknesses is not None: | |
| existing.weaknesses = weaknesses | |
| if study_recommendations is not None: | |
| existing.study_recommendations = study_recommendations | |
| await db.commit() | |
| return existing.id | |
| row = StudentResult( | |
| quiz_id=quiz_id, | |
| student_id=student_id, | |
| score=score, | |
| weaknesses=weaknesses, | |
| study_recommendations=study_recommendations, | |
| ) | |
| db.add(row) | |
| await db.flush() | |
| row_id = row.id | |
| await db.commit() | |
| return row_id | |
| async def wipe_quiz_data(quiz_id: int) -> dict: | |
| """Delete all data for a quiz (results, reports, grids, parsed_data, files) but keep the quiz record.""" | |
| async with get_sessionmaker()() as db: | |
| r1 = await db.execute(delete(Report).where(Report.quiz_id == quiz_id)) | |
| r2 = await db.execute(delete(StudentResult).where(StudentResult.quiz_id == quiz_id)) | |
| r3 = await db.execute(delete(AnswerGridRecord).where(AnswerGridRecord.quiz_id == quiz_id)) | |
| r4 = await db.execute(delete(ParsedData).where(ParsedData.quiz_id == quiz_id)) | |
| r5 = await db.execute(delete(RawFile).where(RawFile.quiz_id == quiz_id)) | |
| await db.commit() | |
| return { | |
| "reports": r1.rowcount, | |
| "results": r2.rowcount, | |
| "grids": r3.rowcount, | |
| "parsed_data": r4.rowcount, | |
| "files": r5.rowcount, | |
| } | |
| async def wipe_question_bank(teacher_id: int | None = None) -> int: | |
| """Delete question bank rows. If teacher_id given, only that teacher's rows; else all rows.""" | |
| async with get_sessionmaker()() as db: | |
| q = delete(QuestionBank) | |
| if teacher_id is not None: | |
| q = q.where(QuestionBank.teacher_id == teacher_id) | |
| r = await db.execute(q) | |
| await db.commit() | |
| return r.rowcount | |
| async def delete_student_result(quiz_id: int, student_id: int) -> bool: | |
| """Delete one student's result for a quiz. Returns True if a row was deleted.""" | |
| async with get_sessionmaker()() as db: | |
| res = await db.execute( | |
| select(StudentResult) | |
| .where(StudentResult.quiz_id == quiz_id) | |
| .where(StudentResult.student_id == student_id) | |
| ) | |
| row = res.scalar_one_or_none() | |
| if not row: | |
| return False | |
| await db.delete(row) | |
| await db.commit() | |
| return True | |
| def _normalize_name_simple(s: str) -> str: | |
| return " ".join((s or "").strip().lower().split()) | |
| async def get_student_timeline(student_id: int) -> list[dict]: | |
| """One student's results across all quizzes, oldest first — the trajectory. | |
| Enriches each entry with total_questions, correct_count, and per-answer data | |
| derived from the stored answer grid (answer_grids table). | |
| """ | |
| async with get_sessionmaker()() as db: | |
| # Fetch results + quiz metadata + student name in one query | |
| res = await db.execute( | |
| select(StudentResult, Quiz.title, Quiz.subject, Quiz.created_at, Quiz.quiz_date, Student.name) | |
| .join(Quiz, StudentResult.quiz_id == Quiz.id) | |
| .join(Student, StudentResult.student_id == Student.id) | |
| .where(StudentResult.student_id == student_id) | |
| .where(Quiz.teacher_id == Student.teacher_id) | |
| # Order by the exam's actual date (quiz_date), not upload order — | |
| # a quiz entered later for an earlier exam date must still land in | |
| # its correct chronological spot on the student's graph. Fall back | |
| # to created_at's date for quizzes with no quiz_date set, and use | |
| # created_at as a tiebreaker for same-day quizzes. | |
| .order_by(func.coalesce(Quiz.quiz_date, cast(Quiz.created_at, Date)), Quiz.created_at) | |
| ) | |
| rows = res.all() | |
| if not rows: | |
| return [] | |
| # Batch-load answer grids for all quizzes in one query | |
| quiz_ids = [sr.quiz_id for sr, *_ in rows] | |
| grid_res = await db.execute( | |
| select(AnswerGridRecord.quiz_id, AnswerGridRecord.grid_json) | |
| .where(AnswerGridRecord.quiz_id.in_(quiz_ids)) | |
| ) | |
| grids: dict[int, dict] = {qid: gj for qid, gj in grid_res.all()} | |
| out = [] | |
| for sr, title, subject, created, quiz_date, student_name in rows: | |
| d = _row(sr) | |
| d["quiz_title"] = title | |
| d["quiz_subject"] = subject | |
| d["quiz_created_at"] = created | |
| d["quiz_date"] = str(quiz_date) if quiz_date else None | |
| # Derive correct_count, total_questions, answers from answer grid | |
| grid_json = grids.get(sr.quiz_id) | |
| if grid_json and isinstance(grid_json, dict): | |
| official: list = grid_json.get("official_answers") or [] | |
| total = grid_json.get("total_questions") or len(official) | |
| norm = _normalize_name_simple(student_name) | |
| for gs in grid_json.get("students") or []: | |
| if _normalize_name_simple(gs.get("name", "")) == norm: | |
| raw_answers = gs.get("answers") or [] | |
| correct = 0 | |
| for j, cell in enumerate(raw_answers): | |
| eff = official[j] if cell == "=" and j < len(official) else cell | |
| if eff and j < len(official) and eff == official[j]: | |
| correct += 1 | |
| d["total_questions"] = total | |
| d["correct_count"] = correct | |
| d["answers"] = {"answers": raw_answers} | |
| break | |
| else: | |
| # Student not found in grid (name mismatch) — fall back to score | |
| d["total_questions"] = total or 0 | |
| d["correct_count"] = None | |
| d["answers"] = {"answers": []} | |
| else: | |
| d["total_questions"] = 0 | |
| d["correct_count"] = None | |
| d["answers"] = {"answers": []} | |
| 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.flush() | |
| row_id = row.id | |
| await db.commit() | |
| 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.flush() | |
| row_id = row.id | |
| await db.commit() | |
| 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(teacher_id: Optional[int] = None) -> dict: | |
| """Global stats (admin) or stats scoped to one teacher's own data.""" | |
| async with get_sessionmaker()() as db: | |
| async def count(stmt): | |
| return (await db.execute(stmt)).scalar_one() | |
| if teacher_id is None: | |
| return { | |
| "teachers": await count(select(func.count(Teacher.id))), | |
| "students": await count(select(func.count(Student.id))), | |
| "quizzes": await count(select(func.count(Quiz.id)).where(Quiz.status != "draft")), | |
| "reports": await count(select(func.count(Report.id))), | |
| "student_results": await count(select(func.count(StudentResult.id))), | |
| "prompts": await count(select(func.count(Prompt.id))), | |
| } | |
| # Scoped to one teacher: own students/quizzes/prompts directly; reports and | |
| # results are owned transitively through the teacher's own quizzes. | |
| own_quiz = select(Quiz.id).where(Quiz.teacher_id == teacher_id).scalar_subquery() | |
| return { | |
| "teachers": 1, | |
| "students": await count( | |
| select(func.count(Student.id)).where(Student.teacher_id == teacher_id) | |
| ), | |
| "quizzes": await count( | |
| select(func.count(Quiz.id)).where(Quiz.teacher_id == teacher_id, Quiz.status != "draft") | |
| ), | |
| "reports": await count( | |
| select(func.count(Report.id)).where(Report.quiz_id.in_(own_quiz)) | |
| ), | |
| "student_results": await count( | |
| select(func.count(StudentResult.id)).where(StudentResult.quiz_id.in_(own_quiz)) | |
| ), | |
| "prompts": await count( | |
| select(func.count(Prompt.id)).where(Prompt.teacher_id == teacher_id) | |
| ), | |
| } | |
| async def admin_list_students(teacher_id: Optional[int] = None, limit: int = 500) -> list[dict]: | |
| """Students with quiz count + avg score; all teachers (admin) or one teacher's own.""" | |
| async with get_sessionmaker()() as db: | |
| stmt = ( | |
| select(Student, Teacher.email) | |
| .join(Teacher, Student.teacher_id == Teacher.id) | |
| .order_by(Student.name) | |
| .limit(limit) | |
| ) | |
| if teacher_id is not None: | |
| stmt = stmt.where(Student.teacher_id == teacher_id) | |
| res = await db.execute(stmt) | |
| rows = res.all() | |
| # One aggregate query for all students instead of one round-trip per | |
| # student — the per-student version turned a class of 30 into 31 | |
| # sequential DB calls before the list could render. | |
| student_ids = [s.id for s, _ in rows] | |
| agg_map: dict[int, tuple[int, Optional[float]]] = {} | |
| if student_ids: | |
| agg_res = await db.execute( | |
| select( | |
| StudentResult.student_id, | |
| func.count(StudentResult.id), | |
| func.avg(StudentResult.score), | |
| ) | |
| .where(StudentResult.student_id.in_(student_ids)) | |
| .group_by(StudentResult.student_id) | |
| ) | |
| agg_map = {sid: (count, avg) for sid, count, avg in agg_res.all()} | |
| out = [] | |
| for s, email in rows: | |
| d = _row(s) | |
| d["teacher_email"] = email | |
| count, avg = agg_map.get(s.id, (0, None)) | |
| d["result_count"] = count | |
| d["avg_score"] = round(float(avg), 1) if avg is not None else None | |
| out.append(d) | |
| return out | |
| async def get_quiz_results(quiz_id: int) -> list[dict]: | |
| """All student results for a quiz, joined with student name (admin detail).""" | |
| async with get_sessionmaker()() as db: | |
| res = await db.execute( | |
| select(StudentResult, Student.name, Student.id) | |
| .join(Student, StudentResult.student_id == Student.id) | |
| .join(Quiz, StudentResult.quiz_id == Quiz.id) | |
| .where(StudentResult.quiz_id == quiz_id) | |
| # defense-in-depth: only students belonging to this quiz's teacher | |
| .where(Student.teacher_id == Quiz.teacher_id) | |
| .order_by(Student.name) | |
| ) | |
| out = [] | |
| for sr, name, sid in res.all(): | |
| d = _row(sr) | |
| d["student_name"] = name | |
| d["student_id"] = sid | |
| 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(teacher_id: Optional[int] = None, limit: int = 200) -> list[dict]: | |
| """Quizzes with author email + counts; all teachers (admin) or one teacher's own. | |
| Only includes status "saved" — a quiz becomes a record here only once the | |
| teacher finishes the whole flow and clicks "儲存至資料庫" in step 3. | |
| Uploaded-but-abandoned drafts and half-finished sessions (uploaded, maybe a | |
| few reports generated, but never explicitly saved) never show up here, so | |
| this list never contains a quiz with 0 reports. | |
| """ | |
| async with get_sessionmaker()() as db: | |
| stmt = ( | |
| select(Quiz, Teacher.email) | |
| .join(Teacher, Quiz.teacher_id == Teacher.id) | |
| .where(Quiz.status == "saved") | |
| .order_by(Quiz.created_at.desc()) | |
| .limit(limit) | |
| ) | |
| if teacher_id is not None: | |
| stmt = stmt.where(Quiz.teacher_id == teacher_id) | |
| res = await db.execute(stmt) | |
| rows = res.all() | |
| # One aggregate query for all quizzes instead of one COUNT round-trip | |
| # per quiz — same fix as admin_list_students' N+1. | |
| quiz_ids = [q.id for q, _ in rows] | |
| report_counts: dict[int, int] = {} | |
| if quiz_ids: | |
| count_res = await db.execute( | |
| select(Report.quiz_id, func.count(Report.id)) | |
| .where(Report.quiz_id.in_(quiz_ids)) | |
| .group_by(Report.quiz_id) | |
| ) | |
| report_counts = dict(count_res.all()) | |
| out = [] | |
| for q, email in rows: | |
| d = _row(q) | |
| d["teacher_email"] = email | |
| d["report_count"] = report_counts.get(q.id, 0) | |
| out.append(d) | |
| return out | |
| # ============================================================================= | |
| # Practice quizzes (individualized, student-facing, no login required) | |
| # ============================================================================= | |
| async def create_practice_quiz( | |
| teacher_id: int, | |
| student_id: int, | |
| title: str, | |
| questions: list[dict], | |
| feed_to_dashboard: bool = True, | |
| ) -> dict: | |
| """Create an individualized practice quiz for one student. | |
| `questions` is a list of {number, question, options: {A,B,C,D}, answer, | |
| category}. The share_token is the sole credential for the public | |
| student-facing link — unguessable and unique, no separate login. | |
| """ | |
| async with get_sessionmaker()() as db: | |
| quiz = PracticeQuiz( | |
| teacher_id=teacher_id, | |
| student_id=student_id, | |
| title=title or "Practice Quiz", | |
| share_token=secrets.token_urlsafe(24), | |
| questions=questions, | |
| feed_to_dashboard=feed_to_dashboard, | |
| status="sent", | |
| ) | |
| db.add(quiz) | |
| await db.flush() | |
| row = _row(quiz) | |
| await db.commit() | |
| return row | |
| async def get_practice_quiz(practice_quiz_id: int, teacher_id: int) -> Optional[dict]: | |
| """Teacher-owned lookup (for the management view), scoped to the caller.""" | |
| async with get_sessionmaker()() as db: | |
| res = await db.execute( | |
| select(PracticeQuiz) | |
| .where(PracticeQuiz.id == practice_quiz_id) | |
| .where(PracticeQuiz.teacher_id == teacher_id) | |
| ) | |
| quiz = res.scalar_one_or_none() | |
| return _row(quiz) if quiz else None | |
| async def get_practice_quiz_by_token(share_token: str) -> Optional[dict]: | |
| """Public lookup by share token — the token itself is the authorization, | |
| there is no teacher_id scoping here by design. | |
| """ | |
| async with get_sessionmaker()() as db: | |
| res = await db.execute( | |
| select(PracticeQuiz).where(PracticeQuiz.share_token == share_token) | |
| ) | |
| quiz = res.scalar_one_or_none() | |
| return _row(quiz) if quiz else None | |
| async def list_practice_quizzes(teacher_id: int, student_id: Optional[int] = None) -> list[dict]: | |
| async with get_sessionmaker()() as db: | |
| stmt = ( | |
| select(PracticeQuiz) | |
| .where(PracticeQuiz.teacher_id == teacher_id) | |
| .order_by(PracticeQuiz.created_at.desc()) | |
| ) | |
| if student_id is not None: | |
| stmt = stmt.where(PracticeQuiz.student_id == student_id) | |
| res = await db.execute(stmt) | |
| return [_row(q) for q in res.scalars().all()] | |
| async def submit_practice_quiz(share_token: str, answers: dict[str, str]) -> Optional[dict]: | |
| """Grade and record a single-attempt submission. | |
| Returns None if the token doesn't match any quiz. If the quiz was already | |
| completed, returns the existing (already-graded) row unchanged rather | |
| than re-grading — single attempt only, resubmits are a no-op. | |
| """ | |
| async with get_sessionmaker()() as db: | |
| res = await db.execute( | |
| select(PracticeQuiz).where(PracticeQuiz.share_token == share_token) | |
| ) | |
| quiz = res.scalar_one_or_none() | |
| if not quiz: | |
| return None | |
| if quiz.status == "completed": | |
| return _row(quiz) | |
| questions = quiz.questions or [] | |
| correct = 0 | |
| for q in questions: | |
| num = str(q.get("number")) | |
| given = (answers.get(num) or "").strip().upper() | |
| if given and given == (q.get("answer") or "").strip().upper(): | |
| correct += 1 | |
| total = len(questions) | |
| score = round((correct / total) * 100, 1) if total else 0.0 | |
| quiz.answers = answers | |
| quiz.score = score | |
| quiz.correct_count = correct | |
| quiz.status = "completed" | |
| quiz.completed_at = _now() | |
| await db.commit() | |
| result = _row(quiz) | |
| if result.get("feed_to_dashboard"): | |
| await _feed_practice_quiz_to_dashboard(result) | |
| return result | |
| async def _feed_practice_quiz_to_dashboard(pq: dict) -> None: | |
| """Record this practice attempt as a StudentResult (under a lightweight, | |
| synthetic Quiz row) so it slots into the student's existing timeline, | |
| average score, and AI-summary pipeline for free. | |
| Uses status="practice" (not "saved") so it never shows up in the | |
| teacher's 我的資料 exam/session list — that list is for real uploaded | |
| exams, and a practice quiz has no report to show there. | |
| """ | |
| async with get_sessionmaker()() as db: | |
| quiz = Quiz( | |
| teacher_id=pq["teacher_id"], | |
| title=pq.get("title") or "Practice Quiz", | |
| status="practice", | |
| quiz_date=_now().date(), | |
| ) | |
| db.add(quiz) | |
| await db.flush() | |
| db.add(StudentResult( | |
| quiz_id=quiz.id, | |
| student_id=pq["student_id"], | |
| score=pq["score"], | |
| )) | |
| await db.commit() | |