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