"""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, timedelta, timezone from typing import Awaitable, Callable, 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 .taxonomy import MAIN_CATEGORIES from .models import ( AnswerGrid as AnswerGridRecord, Base, IssueReport, LoginEvent, ParsedData, PracticeQuiz, Prompt, Quiz, QuestionBank, RawFile, Report, Student, StudentResult, Teacher, ) def _now() -> datetime: return datetime.now(timezone.utc) def _aware(dt: datetime) -> datetime: """Postgres (DateTime(timezone=True)) round-trips tz-aware datetimes; SQLite always hands back naive ones regardless of column type. Anything comparing a DB-fetched datetime against another in plain Python (not a SQL WHERE clause, which each dialect handles consistently on its own) needs both sides on the same footing — assume naive values are UTC, since every datetime this app writes is generated via _now().""" return dt if dt.tzinfo else dt.replace(tzinfo=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 get_user_by_google_id(google_id: str) -> Optional[dict]: async with get_sessionmaker()() as db: res = await db.execute(select(Teacher).where(Teacher.google_id == google_id)) teacher = res.scalar_one_or_none() return _row(teacher) if teacher else None async def create_google_user( email: str, google_id: str, full_name: str = "", is_admin: bool = False ) -> int: """Create a Google-only teacher (no password) and return its id.""" async with get_sessionmaker()() as db: teacher = Teacher( email=email, password_hash=None, google_id=google_id, display_name=full_name or email.split("@")[0], full_name=full_name, is_admin=is_admin, ) db.add(teacher) await db.flush() teacher_id = teacher.id await db.commit() return teacher_id async def link_google_id(user_id: int, google_id: str) -> None: """Attach a Google account to an existing (password) teacher.""" async with get_sessionmaker()() as db: await db.execute( update(Teacher).where(Teacher.id == user_id).values(google_id=google_id) ) await db.commit() 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 get_teacher_vocabulary(teacher_id: int) -> list[dict]: async with get_sessionmaker()() as db: teacher = await db.get(Teacher, teacher_id) return list(teacher.vocabulary) if teacher else [] async def set_teacher_vocabulary(teacher_id: int, words: list[dict]) -> list[dict]: async with get_sessionmaker()() as db: await db.execute( update(Teacher).where(Teacher.id == teacher_id).values(vocabulary=words) ) await db.commit() return words 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 get_admin_emails() -> list[str]: """Every teacher with is_admin=True in the DB — used as the recipient list for issue-report notifications. Doesn't include email-based admin overrides granted only in code (see auth._apply_admin_override); callers that need those too should merge them in separately.""" async with get_sessionmaker()() as db: res = await db.execute(select(Teacher.email).where(Teacher.is_admin.is_(True))) return [e for (e,) in res.all()] async def create_issue_report( reporter_email: str, category: str, subject: str, description: str, page_context: str = "", ) -> dict: async with get_sessionmaker()() as db: report = IssueReport( reporter_email=reporter_email, category=category, subject=subject, description=description, page_context=page_context, ) db.add(report) await db.flush() await db.refresh(report) row = _row(report) await db.commit() return row async def list_open_issue_reports() -> list[dict]: async with get_sessionmaker()() as db: res = await db.execute( select(IssueReport) .where(IssueReport.status == "open") .order_by(IssueReport.created_at.desc()) ) return [_row(r) for r in res.scalars().all()] async def dismiss_issue_report(report_id: int) -> bool: async with get_sessionmaker()() as db: res = await db.execute( update(IssueReport) .where(IssueReport.id == report_id, IssueReport.status == "open") .values(status="dismissed", dismissed_at=_now()) ) await db.commit() return res.rowcount > 0 # type: ignore[attr-defined] 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]: """A teacher's saved quiz records — filtered to status == "saved" so an upload that's mid-flow or was abandoned before step 3's explicit save never appears as a record here, matching admin_list_sessions.""" async with get_sessionmaker()() as db: res = await db.execute( select(Quiz) .where(Quiz.teacher_id == user_id, Quiz.status == "saved") .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() # ============================================================================= # 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 save_raw_file( quiz_id: int, data_type: str, file_name: str, content_type: str, size_bytes: int, storage_path: str, ) -> int: """Record an uploaded file's on-disk location so the original scan/PDF can be viewed later instead of only the OCR-extracted structured data.""" async with get_sessionmaker()() as db: raw_file = RawFile( quiz_id=quiz_id, data_type=data_type, file_name=file_name, content_type=content_type, size_bytes=size_bytes, storage_path=storage_path, ) db.add(raw_file) await db.commit() await db.refresh(raw_file) return raw_file.id async def get_raw_files(quiz_id: int) -> list[dict]: async with get_sessionmaker()() as db: res = await db.execute( select(RawFile) .where(RawFile.quiz_id == quiz_id) .order_by(RawFile.created_at) ) return [_row(f) for f in res.scalars().all()] async def get_raw_file(raw_file_id: int) -> Optional[dict]: async with get_sessionmaker()() as db: res = await db.execute(select(RawFile).where(RawFile.id == raw_file_id)) f = res.scalar_one_or_none() return _row(f) if f else None 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], ) -> dict[str, int]: """Insert categorized questions into the shared question bank. `questions` is a list of {question_text, answer, main_category, tags, passage}. `passage` is optional — for a passage/stimulus-based set (one reading passage with several questions attached), pass the *same* passage text on each sibling question's dict; each becomes its own row (the model has no separate passages table — see QuestionBank.passage), so the text is duplicated across siblings, but every row stays self-contained and query_question_bank/generation code needs no special-casing for "which questions share a passage." Skips questions with empty question_text and exact-text duplicates. Completeness (answer present, no unresolved image reference) is not checked here — that heuristic was rejecting verified-complete uploads, so it was removed from this save path. The equivalent safeguard for generation-time context sufficiency still lives in question_generator._has_answerable_context. Returns {"inserted", "duplicates", "incomplete"} for API-shape compatibility; "incomplete" is always 0 now. """ texts = [q["question_text"] for q in questions if q.get("question_text")] if not texts: return {"inserted": 0, "duplicates": 0, "incomplete": 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 duplicates = 0 for q in questions: if not q.get("question_text"): continue if q["question_text"] in existing: duplicates += 1 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", []), passage=q.get("passage") or None, ) ) inserted += 1 await db.commit() return {"inserted": inserted, "duplicates": duplicates, "incomplete": 0} 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] async def backfill_question_bank_answers( quiz_id: int, answers_by_text: dict[str, str] ) -> int: """Fill in `answer` on question_bank rows from this quiz's confirmed answer grid. Called once a teacher confirms the answer grid (the point the correct answer per question becomes actually known) — lets question generation skip the LLM answer-check for these rows from then on. Only overwrites rows that don't already have an answer; matches by exact question_text since that's how these rows were seeded (see `save_question_bank_batch`). Returns the number of rows updated. """ if not answers_by_text: return 0 async with get_sessionmaker()() as db: res = await db.execute( select(QuestionBank.id, QuestionBank.question_text) .where(QuestionBank.quiz_id == quiz_id) .where((QuestionBank.answer == "") | (QuestionBank.answer.is_(None))) ) updated = 0 for row_id, text in res.all(): answer = answers_by_text.get((text or "").strip()) if answer: await db.execute( update(QuestionBank) .where(QuestionBank.id == row_id) .values(answer=answer) ) updated += 1 await db.commit() return updated async def save_question_bank_passage(question_id: int, passage: str) -> None: """Backfill a generated reading passage onto a question_bank row. Best-effort — called after the LLM writes a passage for a row that was sampled without one, so future draws of the same row reuse it instead of paying to regenerate. """ async with get_sessionmaker()() as db: await db.execute( update(QuestionBank) .where(QuestionBank.id == question_id) .values(passage=passage) ) await db.commit() # ---- 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() # ============================================================================= # 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_student_by_username(username: str) -> Optional[dict]: async with get_sessionmaker()() as db: res = await db.execute(select(Student).where(Student.username == username)) student = res.scalar_one_or_none() return _row(student) if student else None async def username_taken(username: str) -> bool: async with get_sessionmaker()() as db: res = await db.execute(select(Student.id).where(Student.username == username)) return res.scalar_one_or_none() is not None async def set_student_login( student_id: int, teacher_id: int, username: str, password_encrypted: str ) -> bool: """Issue or reset a student's login credentials. 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.username = username student.password_encrypted = password_encrypted 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, # type: ignore[attr-defined] "results": r2.rowcount, # type: ignore[attr-defined] "grids": r3.rowcount, # type: ignore[attr-defined] "parsed_data": r4.rowcount, # type: ignore[attr-defined] "files": r5.rowcount, # type: ignore[attr-defined] } async def list_question_bank( category: str | None = None, tag: str | None = None, search: str | None = None, limit: int = 50, offset: int = 0, ) -> tuple[list[dict], int]: """Browse the shared question bank for the 題庫管理 UI. Filters by exact category, a single tag (checked in Python since `tags` is a JSON array column), and/or a substring search on question_text. Returns (page of rows, total matching count) — rows sorted by category then newest first, so browsing naturally groups by tag/category. """ async with get_sessionmaker()() as db: q = select(QuestionBank) if category: q = q.where(QuestionBank.main_category == category) if search: q = q.where(QuestionBank.question_text.ilike(f"%{search}%")) res = await db.execute( q.order_by(QuestionBank.main_category, QuestionBank.created_at.desc()) ) rows = [_row(r) for r in res.scalars().all()] if tag: rows = [r for r in rows if tag in (r.get("tags") or [])] return rows[offset : offset + limit], len(rows) async def list_question_bank_tags() -> dict[str, list[str]]: """Distinct (category -> tags actually in use) for the 題庫管理 filter UI. Always includes all 6 main categories, even ones with zero questions right now (e.g. a teacher just cleared out everything in one category) — otherwise that category's filter button would vanish entirely instead of just showing empty, which reads as "this category doesn't exist" rather than "this category has nothing in it yet." """ by_cat: dict[str, set[str]] = {cat: set() for cat in MAIN_CATEGORIES} async with get_sessionmaker()() as db: res = await db.execute(select(QuestionBank.main_category, QuestionBank.tags)) for cat, tags in res.all(): bucket = by_cat.setdefault(cat or "", set()) bucket.update(tags or []) return {cat: sorted(tags) for cat, tags in by_cat.items()} async def delete_question_bank_row(row_id: int) -> bool: async with get_sessionmaker()() as db: res = await db.execute(select(QuestionBank).where(QuestionBank.id == row_id)) row = res.scalar_one_or_none() if not row: return False await db.delete(row) await db.commit() return True 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 # type: ignore[attr-defined] 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) # Only quizzes the teacher actually saved (or practice-quiz # completions) belong on the dashboard — a report generated in # Step 3 writes a StudentResult immediately, before "Save to # Database" is clicked, so without this filter the dashboard # would show results for quizzes that never got saved. .where(Quiz.status.in_(["saved", "practice"])) # 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()} # Batch-load this student's own report id per quiz, for the report/raw-file # links on the dashboard — a quiz can have reports for other students too, # so this is scoped by student_id, not just quiz_id. report_res = await db.execute( select(Report.quiz_id, Report.id) .where(Report.quiz_id.in_(quiz_ids)) .where(Report.student_id == student_id) ) report_ids: dict[int, int] = {qid: rid for qid, rid in report_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 d["report_id"] = report_ids.get(sr.quiz_id) # 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 == "saved") ), "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 == "saved" ) ), "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 get_signup_growth() -> dict: """Cumulative teacher/student signups by day, for the admin analytics chart. Computed directly from each row's created_at rather than a separate snapshot table — signup date is immutable once a row exists, so this gives a fully accurate history (including before this feature shipped) with nothing to backfill or keep in sync. """ async with get_sessionmaker()() as db: async def daily_counts(model) -> list[tuple[str, int]]: # func.date() (not cast(..., Date)) — casting a datetime column to # Date and selecting it directly trips a SQLAlchemy/SQLite result- # decoding bug (the cast is a no-op at the SQLite level, so the # value comes back as a full timestamp string that the Date type's # Python-side parser then rejects). func.date() truncates to # 'YYYY-MM-DD' at the SQL level instead, which is portable to # Postgres too and never hits that decode path. day = func.date(model.created_at) res = await db.execute( select(day, func.count()).group_by(day).order_by(day) ) return [(str(d), c) for d, c in res.all()] def cumulative(rows: list[tuple[str, int]]) -> list[dict]: out = [] total = 0 for d, c in rows: total += c out.append({"date": d, "new": c, "total": total}) return out return { "teachers": cumulative(await daily_counts(Teacher)), "students": cumulative(await daily_counts(Student)), } async def record_login_event(role: str, user_id: int) -> None: """Append-only log of a successful login — see LoginEvent for why this is a separate table rather than reusing Teacher.last_login (which is overwritten each time, so it can't answer "how many distinct users logged in this week/month").""" async with get_sessionmaker()() as db: db.add(LoginEvent(role=role, user_id=user_id)) await db.commit() async def get_active_users(weeks: int = 12) -> dict: """Weekly active teachers/students (distinct users with >=1 login) over the last `weeks` weeks, for the admin analytics line chart. Bucketed in Python (not SQL date-trunc) — the two DB backends this app runs on (SQLite locally, Postgres in production) don't share a portable "start of ISO week" expression, and login-event volume is small enough that fetching raw rows and bucketing here is simpler than reconciling dialect-specific date functions. """ cutoff = _now() - timedelta(weeks=weeks) async with get_sessionmaker()() as db: res = await db.execute( select(LoginEvent.role, LoginEvent.user_id, LoginEvent.created_at).where( LoginEvent.created_at >= cutoff ) ) rows = res.all() def week_start(dt: datetime) -> str: d = dt.date() if hasattr(dt, "date") else dt return (d - timedelta(days=d.weekday())).isoformat() buckets: dict[str, dict[str, set]] = {} for role, user_id, created_at in rows: wk = week_start(created_at) buckets.setdefault(wk, {"teacher": set(), "student": set()})[role].add(user_id) weeks_sorted = sorted(buckets) return { "teachers": [ {"date": wk, "count": len(buckets[wk]["teacher"])} for wk in weeks_sorted ], "students": [ {"date": wk, "count": len(buckets[wk]["student"])} for wk in weeks_sorted ], } async def get_teacher_retention() -> dict: """Of teachers whose account is old enough to judge, what share came back and logged in again *after* their first week / first month — not just during initial signup/onboarding. Shows sustained use rather than one-time testing. A teacher only counts toward a window's denominator once their account has existed at least that long (a 3-day-old account can't yet prove or disprove week-1 retention), and toward the numerator if any login_event for them lands strictly after `created_at + window`. """ now = _now() async with get_sessionmaker()() as db: teachers_res = await db.execute(select(Teacher.id, Teacher.created_at)) teachers = [(tid, _aware(created_at)) for tid, created_at in teachers_res.all()] logins_res = await db.execute( select(LoginEvent.user_id, LoginEvent.created_at).where( LoginEvent.role == "teacher" ) ) logins_by_teacher: dict[int, list[datetime]] = {} for user_id, created_at in logins_res.all(): logins_by_teacher.setdefault(user_id, []).append(_aware(created_at)) def window_stats(days: int) -> dict: eligible = 0 retained = 0 for teacher_id, created_at in teachers: cutoff = created_at + timedelta(days=days) if cutoff > now: continue # account not old enough to judge this window yet eligible += 1 if any(t > cutoff for t in logins_by_teacher.get(teacher_id, [])): retained += 1 pct = round(100 * retained / eligible, 1) if eligible else 0.0 return {"eligible": eligible, "retained": retained, "pct": pct} return { "week": window_stats(7), "month": window_stats(30), } async def get_assessments_processed(months: int = 6) -> dict: """Monthly counts of exams uploaded (saved quizzes) and student reports generated, for the admin analytics bar chart.""" cutoff = _now() - timedelta(days=months * 31) async with get_sessionmaker()() as db: quiz_res = await db.execute( select(Quiz.created_at).where( Quiz.status == "saved", Quiz.created_at >= cutoff ) ) report_res = await db.execute( select(Report.created_at).where(Report.created_at >= cutoff) ) def month_bucket(dt: datetime) -> str: return f"{dt.year:04d}-{dt.month:02d}" def counts_by_month(rows) -> dict[str, int]: out: dict[str, int] = {} for (dt,) in rows: key = month_bucket(dt) out[key] = out.get(key, 0) + 1 return out quizzes_by_month = counts_by_month(quiz_res.all()) reports_by_month = counts_by_month(report_res.all()) all_months = sorted(set(quizzes_by_month) | set(reports_by_month)) return { "months": [ { "month": m, "quizzes_uploaded": quizzes_by_month.get(m, 0), "reports_generated": reports_by_month.get(m, 0), } for m in all_months ] } async def mark_practice_quiz_opened(share_token: str) -> None: """Set opened_at the first time a student loads the quiz link — a no-op if already set, so repeated page loads/refreshes don't matter.""" async with get_sessionmaker()() as db: await db.execute( update(PracticeQuiz) .where( PracticeQuiz.share_token == share_token, PracticeQuiz.opened_at.is_(None), ) .values(opened_at=func.now()) ) await db.commit() async def get_practice_quiz_funnel(teacher_id: Optional[int] = None) -> dict: """generated -> assigned -> opened -> completed counts for the admin funnel chart. "generated" and "assigned" are currently always equal (a PracticeQuiz row is created with status="sent" in the same insert, see create_practice_quiz) — kept as separate stages since sharing is a distinct product step even though today it's not a separate DB event.""" async with get_sessionmaker()() as db: stmt = select(PracticeQuiz) if teacher_id is not None: stmt = stmt.where(PracticeQuiz.teacher_id == teacher_id) res = await db.execute(stmt) rows = res.scalars().all() generated = len(rows) assigned = sum(1 for r in rows if r.status in ("sent", "completed")) opened = sum(1 for r in rows if r.opened_at is not None) completed = sum(1 for r in rows if r.status == "completed") return { "generated": generated, "assigned": assigned, "opened": opened, "completed": completed, } async def get_feedback_loop_funnel(teacher_id: Optional[int] = None) -> dict: """Exam result → weakness identified → practice generated → practice completed → later reassessment, for the admin funnel chart. The unit here is one student's result on one real exam (a StudentResult row on a Quiz with status="saved" and no source_practice_quiz_id — i.e. not the synthetic quiz row a completed practice quiz feeds into the dashboard as). PracticeQuiz has no FK back to the exam that triggered it, so each stage is correlated by student + timestamp ordering instead: the practice quiz counted for a given exam result is the earliest one generated for that student after the exam, and a "reassessment" is any later real exam for that same student after that practice was completed. """ async with get_sessionmaker()() as db: quiz_stmt = select(Quiz.id, Quiz.created_at).where( Quiz.status == "saved", Quiz.source_practice_quiz_id.is_(None) ) if teacher_id is not None: quiz_stmt = quiz_stmt.where(Quiz.teacher_id == teacher_id) quiz_res = await db.execute(quiz_stmt) quiz_created_at: dict[int, datetime] = { qid: _aware(dt) for qid, dt in quiz_res.all() } empty = { "uploaded": 0, "weakness_identified": 0, "practice_generated": 0, "practice_completed": 0, "reassessed": 0, } if not quiz_created_at: return empty result_res = await db.execute( select( StudentResult.quiz_id, StudentResult.student_id, StudentResult.weaknesses, ).where(StudentResult.quiz_id.in_(quiz_created_at.keys())) ) results = result_res.all() if not results: return empty student_ids = {sid for _, sid, _ in results} pq_stmt = select( PracticeQuiz.student_id, PracticeQuiz.created_at, PracticeQuiz.completed_at, PracticeQuiz.status, ).where(PracticeQuiz.student_id.in_(student_ids)) if teacher_id is not None: pq_stmt = pq_stmt.where(PracticeQuiz.teacher_id == teacher_id) pq_res = await db.execute(pq_stmt) practice_by_student: dict[int, list[tuple]] = {} for sid, created_at, completed_at, status in pq_res.all(): practice_by_student.setdefault(sid, []).append( ( _aware(created_at), _aware(completed_at) if completed_at else None, status, ) ) # Every real exam per student (quiz_id kept so a student's own # triggering exam is never counted as its own "later" reassessment). exam_stmt = ( select(StudentResult.student_id, Quiz.id, Quiz.created_at) .join(Quiz, StudentResult.quiz_id == Quiz.id) .where(Quiz.status == "saved", Quiz.source_practice_quiz_id.is_(None)) ) if teacher_id is not None: exam_stmt = exam_stmt.where(Quiz.teacher_id == teacher_id) exam_res = await db.execute(exam_stmt) exams_by_student: dict[int, list[tuple]] = {} for sid, qid, created_at in exam_res.all(): exams_by_student.setdefault(sid, []).append((qid, _aware(created_at))) uploaded = len(results) weakness_identified = 0 practice_generated = 0 practice_completed = 0 reassessed = 0 for quiz_id, student_id, weaknesses in results: if not (weaknesses or "").strip(): continue weakness_identified += 1 quiz_time = quiz_created_at[quiz_id] practices_after = sorted( (p for p in practice_by_student.get(student_id, []) if p[0] > quiz_time), key=lambda p: p[0], ) if not practices_after: continue practice_generated += 1 _, completed_at, status = practices_after[0] if status != "completed" or completed_at is None: continue practice_completed += 1 later_exams = [ t for other_qid, t in exams_by_student.get(student_id, []) if other_qid != quiz_id and t > completed_at ] if later_exams: reassessed += 1 return { "uploaded": uploaded, "weakness_identified": weakness_identified, "practice_generated": practice_generated, "practice_completed": practice_completed, "reassessed": reassessed, } async def get_all_students_category_history( teacher_id: Optional[int] = None, ) -> dict[int, dict[str, list[dict]]]: """Per-student category accuracy breakdown (results+quiz join, answer grids, parsed-data categories), scoped to every student (or one teacher's) in ONE round trip instead of one per student. Exists specifically for get_student_improvement_summary — a school with a few hundred students would otherwise mean hundreds of sequential per-student DB round trips on an admin analytics page load; this makes it 3 total, regardless of N. """ async with get_sessionmaker()() as db: stmt = ( select( StudentResult.student_id, StudentResult.quiz_id, Quiz.title, Quiz.quiz_date, Quiz.created_at, Student.name, ) .join(Quiz, StudentResult.quiz_id == Quiz.id) .join(Student, StudentResult.student_id == Student.id) .where(Quiz.teacher_id == Student.teacher_id) .where(Quiz.status.in_(["saved", "practice"])) ) if teacher_id is not None: stmt = stmt.where(Student.teacher_id == teacher_id) stmt = stmt.order_by( StudentResult.student_id, func.coalesce(Quiz.quiz_date, cast(Quiz.created_at, Date)), Quiz.created_at, ) res = await db.execute(stmt) rows = res.all() if not rows: return {} quiz_ids = list({r.quiz_id for r 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()} parsed_res = await db.execute( select( ParsedData.quiz_id, ParsedData.question_num, ParsedData.main_category ) .where(ParsedData.quiz_id.in_(quiz_ids)) .where(ParsedData.main_category != "") ) categories_by_quiz: dict[int, dict[int, str]] = {} for qid, qnum, cat in parsed_res.all(): categories_by_quiz.setdefault(qid, {})[qnum] = cat out: dict[int, dict[str, list[dict]]] = {} for student_id, quiz_id, title, quiz_date, created_at, student_name in rows: cat_map = categories_by_quiz.get(quiz_id) grid_json = grids.get(quiz_id) if not cat_map or not grid_json: continue official: list = grid_json.get("official_answers") or [] norm = _normalize_name_simple(student_name) student_row = next( ( gs for gs in grid_json.get("students") or [] if _normalize_name_simple(gs.get("name", "")) == norm ), None, ) if not student_row: continue raw_answers = student_row.get("answers") or [] tally: dict[str, list[int]] = {} for qnum, category in cat_map.items(): idx = qnum - 1 if idx < 0 or idx >= len(official) or not official[idx]: continue cell = raw_answers[idx] if idx < len(raw_answers) else None effective = official[idx] if cell == "=" else cell counts = tally.setdefault(category, [0, 0]) counts[1] += 1 if effective == official[idx]: counts[0] += 1 date_str = str(quiz_date) if quiz_date else created_at.date().isoformat() student_out = out.setdefault(student_id, {}) for category, (correct, total) in tally.items(): if total == 0: continue student_out.setdefault(category, []).append( { "quiz_id": quiz_id, "quiz_title": title, "quiz_date": date_str, "correct": correct, "total": total, "accuracy": round(correct / total * 100, 1), } ) return out async def get_student_improvement_summary(teacher_id: Optional[int] = None) -> dict: """For each student with enough history in a category, compare their first up-to-3 assessments in that category against their most recent up-to-3 — a student "improved" in a category if recent accuracy exceeds baseline. Requires >=4 data points in a category (fewer than that and the baseline/recent windows would just be the same quizzes, always showing zero change). """ async with get_sessionmaker()() as db: stmt = select(Student.id) if teacher_id is not None: stmt = stmt.where(Student.teacher_id == teacher_id) res = await db.execute(stmt) student_ids = [sid for (sid,) in res.all()] all_history = await get_all_students_category_history(teacher_id) deltas: list[float] = [] improved_students = 0 eligible_students = 0 for sid in student_ids: history = all_history.get(sid, {}) student_deltas = [] for entries in history.values(): n = len(entries) if n < 4: continue baseline = entries[: min(3, n)] recent = entries[-min(3, n) :] baseline_acc = sum(e["accuracy"] for e in baseline) / len(baseline) recent_acc = sum(e["accuracy"] for e in recent) / len(recent) student_deltas.append(recent_acc - baseline_acc) if student_deltas: eligible_students += 1 deltas.extend(student_deltas) if max(student_deltas) > 0: improved_students += 1 # Distribution buckets (percentage-point change), for the histogram. bucket_edges = [-100, -20, -10, 0, 10, 20, 100] bucket_labels = ["<-20", "-20~-10", "-10~0", "0~10", "10~20", ">20"] bucket_counts = [0] * len(bucket_labels) for d in deltas: pct = d * 100 for i in range(len(bucket_labels)): if bucket_edges[i] <= pct < bucket_edges[i + 1] or ( i == len(bucket_labels) - 1 and pct >= bucket_edges[i] ): bucket_counts[i] += 1 break return { "eligible_students": eligible_students, "improved_students": improved_students, "total_students": len(student_ids), "distribution": [ {"label": label, "count": c} for label, c in zip(bucket_labels, bucket_counts) ], } 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 = {qid: count for qid, count in 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() # Keep practice_quizzes from growing unbounded — each student's dashboard # score history lives permanently in StudentResult regardless (see # _feed_practice_quiz_to_dashboard), so trimming old practice-quiz rows # only drops the ability to revisit that specific quiz's exact questions/ # review via its old share link, never a student's actual score history. await _trim_old_practice_quizzes(student_id) return row PRACTICE_QUIZ_KEEP_PER_STUDENT = 50 async def _trim_old_practice_quizzes( student_id: int, keep: Optional[int] = None ) -> int: """Delete all but the `keep` most-recent practice quizzes for a student. Returns the number of rows deleted. `keep` is looked up from the module global at call time (rather than bound as a default argument value) so tests can monkeypatch PRACTICE_QUIZ_KEEP_PER_STUDENT and have it actually take effect — default-argument values are bound once at function-definition time, not per call. """ if keep is None: keep = PRACTICE_QUIZ_KEEP_PER_STUDENT async with get_sessionmaker()() as db: res = await db.execute( select(PracticeQuiz.id) .where(PracticeQuiz.student_id == student_id) # id as a secondary key: created_at alone ties whenever multiple # quizzes are created within the same timestamp resolution # window, which would otherwise make "most recent" ambiguous. .order_by(PracticeQuiz.created_at.desc(), PracticeQuiz.id.desc()) .offset(keep) ) old_ids = [row[0] for row in res.all()] if not old_ids: return 0 await db.execute(delete(PracticeQuiz).where(PracticeQuiz.id.in_(old_ids))) await db.commit() return len(old_ids) 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, Student.name) .join(Student, PracticeQuiz.student_id == Student.id) .where(PracticeQuiz.id == practice_quiz_id) .where(PracticeQuiz.teacher_id == teacher_id) ) row = res.first() if not row: return None quiz, student_name = row d = _row(quiz) d["student_name"] = student_name return d async def delete_practice_quiz(practice_quiz_id: int, teacher_id: int) -> bool: """Delete a practice quiz (invalidates its share link immediately). Scoped to the owning teacher. Returns False if not found/not owned. """ async with get_sessionmaker()() as db: res = await db.execute( delete(PracticeQuiz) .where(PracticeQuiz.id == practice_quiz_id) .where(PracticeQuiz.teacher_id == teacher_id) ) await db.commit() return res.rowcount > 0 # type: ignore[attr-defined] 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_for_student(student_id: int) -> list[dict]: """A student's own assigned practice quizzes — self-scoped by student_id alone (no teacher_id needed, the caller already only knows their own id from their JWT). Powers the "assigned quizzes" banner on the student dashboard. Trimmed (no `questions`/`answers` payload) since the list view only needs enough to show what's pending and link into it. """ async with get_sessionmaker()() as db: res = await db.execute( select(PracticeQuiz) .where(PracticeQuiz.student_id == student_id) .order_by(PracticeQuiz.created_at.desc(), PracticeQuiz.id.desc()) ) return [ { "id": q.id, "title": q.title, "share_token": q.share_token, "status": q.status, "score": q.score, "created_at": q.created_at, "completed_at": q.completed_at, } for q in res.scalars().all() ] async def list_practice_quizzes( teacher_id: int, student_id: Optional[int] = None ) -> list[dict]: async with get_sessionmaker()() as db: stmt = ( select(PracticeQuiz, Student.name) .join(Student, PracticeQuiz.student_id == Student.id) .where(PracticeQuiz.teacher_id == teacher_id) .order_by(PracticeQuiz.created_at.desc(), PracticeQuiz.id.desc()) ) if student_id is not None: stmt = stmt.where(PracticeQuiz.student_id == student_id) res = await db.execute(stmt) out = [] for quiz, student_name in res.all(): d = _row(quiz) d["student_name"] = student_name out.append(d) return out async def submit_practice_quiz( share_token: str, answers: dict[str, str], analyze_fn: Optional[ Callable[[list[dict], dict[str, str]], Awaitable[tuple[str, str]]] ] = None, ) -> 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. `analyze_fn`, if given, is awaited with (questions, answers) right after grading and before feeding the dashboard, so the resulting StudentResult carries weaknesses/study_recommendations the same way a full quiz report does. Kept as an injected callback (rather than importing report_generator directly here) so this data-access module doesn't depend on the LLM layer. """ 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"): weaknesses, study_recommendations = ("", "") if analyze_fn is not None: try: weaknesses, study_recommendations = await analyze_fn(questions, answers) except Exception: pass # non-fatal — the score still gets recorded below await _feed_practice_quiz_to_dashboard( result, weaknesses, study_recommendations ) return result async def _feed_practice_quiz_to_dashboard( pq: dict, weaknesses: str = "", study_recommendations: str = "" ) -> 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(), source_practice_quiz_id=pq["id"], ) db.add(quiz) await db.flush() db.add( StudentResult( quiz_id=quiz.id, student_id=pq["student_id"], score=pq["score"], weaknesses=weaknesses or None, study_recommendations=study_recommendations or None, ) ) await db.commit()