Add database management: Supabase persistence, teacher profiles, admin dashboard

#1
by kuanz - opened
Dockerfile CHANGED
@@ -4,7 +4,7 @@
4
  # =============================================================================
5
  # Stage 1: Build React Frontend
6
  # =============================================================================
7
- FROM node:20-slim AS frontend-builder
8
 
9
  WORKDIR /app/frontend
10
 
@@ -47,6 +47,10 @@ RUN pip install --no-cache-dir --upgrade pip && \
47
  # Copy backend code
48
  COPY --chown=user chatkit/backend/app ./app
49
 
 
 
 
 
50
  # Copy built frontend from Stage 1
51
  COPY --from=frontend-builder --chown=user /app/frontend/dist ./static
52
 
@@ -57,5 +61,6 @@ EXPOSE 7860
57
  HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
58
  CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:7860/health')"
59
 
60
- # Run the server
61
- CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
 
 
4
  # =============================================================================
5
  # Stage 1: Build React Frontend
6
  # =============================================================================
7
+ FROM node:22-slim AS frontend-builder
8
 
9
  WORKDIR /app/frontend
10
 
 
47
  # Copy backend code
48
  COPY --chown=user chatkit/backend/app ./app
49
 
50
+ # Copy Alembic migration config (applied on startup)
51
+ COPY --chown=user chatkit/backend/alembic.ini ./alembic.ini
52
+ COPY --chown=user chatkit/backend/alembic ./alembic
53
+
54
  # Copy built frontend from Stage 1
55
  COPY --from=frontend-builder --chown=user /app/frontend/dist ./static
56
 
 
61
  HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
62
  CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:7860/health')"
63
 
64
+ # Apply DB migrations (Supabase) then run the server.
65
+ # Migrations are a no-op for non-Postgres providers.
66
+ CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 7860"]
chatkit/backend/DATABASE.md ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ClassLens Database
2
+
3
+ Durable data layer for teacher accounts, exam sessions, students, results, and
4
+ reports — the foundation for the admin analytics dashboard and cross-quiz
5
+ student tracking.
6
+
7
+ ## Stack
8
+
9
+ - **Postgres on Supabase** (provider `supabase`), accessed via SQLAlchemy 2.0
10
+ async + `asyncpg`. Local dev/tests can use `sqlite` (provider `sqlite`).
11
+ - All ClassLens tables live in a dedicated **`classlens` schema** so they never
12
+ collide with other apps sharing the same Supabase project.
13
+ - Schema changes are managed by **Alembic** (`alembic/`), applied automatically
14
+ on container startup (`alembic upgrade head`).
15
+
16
+ ## Schema (`classlens.*`)
17
+
18
+ | Table | Purpose |
19
+ |---|---|
20
+ | `teachers` | Accounts: email, password hash, full_name, school, `is_admin` |
21
+ | `students` | First-class student per teacher; unique on `(teacher_id, normalized_name)` for cross-quiz matching |
22
+ | `quizzes` | An exam/upload session (title, subject, status) |
23
+ | `raw_files` | Uploaded source files (metadata; bytes in Supabase Storage) |
24
+ | `parsed_data` | Structured parse of questions/answers + the confirmed answer grid |
25
+ | `student_results` | One student's outcome per quiz — backbone for analytics |
26
+ | `prompts` | Saved report prompts |
27
+ | `reports` | Generated report HTML, linked to quiz + student |
28
+
29
+ ## How data is persisted
30
+
31
+ - **On upload:** raw files are archived to Supabase Storage + `raw_files`;
32
+ parsed structured data is stored in `parsed_data`.
33
+ - **On report generation:** the student is matched/created, a `student_results`
34
+ row is written, and the `reports` row links quiz + student.
35
+ - **"Save to database" button** (`POST /api/sessions/{id}/save-to-db`): commits
36
+ every student in the confirmed grid as `students` + `student_results` and
37
+ marks the quiz `saved`.
38
+
39
+ ## Admin
40
+
41
+ Set `ADMIN_EMAILS` (comma-separated); matching teachers are promoted on startup.
42
+ Admin API is under `/api/admin/*` (stats, users, sessions, students + per-student
43
+ cross-quiz timeline), surfaced in the in-app **管理後台** dashboard.
44
+
45
+ ## Commands
46
+
47
+ ```bash
48
+ # Apply migrations
49
+ alembic upgrade head
50
+
51
+ # Create a new migration after changing models.py
52
+ alembic revision -m "describe change" # then edit, or --autogenerate
53
+
54
+ # Run tests (SQLite, hermetic)
55
+ DATABASE_PROVIDER=sqlite SUPABASE_DB_URL= pytest -q
56
+ ```
chatkit/backend/alembic.ini ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Alembic config for ClassLens. The DB URL is built in alembic/env.py from
2
+ # application settings (Supabase), so sqlalchemy.url is intentionally left blank.
3
+ [alembic]
4
+ script_location = alembic
5
+ prepend_sys_path = .
6
+ version_path_separator = os
7
+
8
+ [loggers]
9
+ keys = root,sqlalchemy,alembic
10
+
11
+ [handlers]
12
+ keys = console
13
+
14
+ [formatters]
15
+ keys = generic
16
+
17
+ [logger_root]
18
+ level = WARNING
19
+ handlers = console
20
+ qualname =
21
+
22
+ [logger_sqlalchemy]
23
+ level = WARNING
24
+ handlers =
25
+ qualname = sqlalchemy.engine
26
+
27
+ [logger_alembic]
28
+ level = INFO
29
+ handlers =
30
+ qualname = alembic
31
+
32
+ [handler_console]
33
+ class = StreamHandler
34
+ args = (sys.stderr,)
35
+ level = NOTSET
36
+ formatter = generic
37
+
38
+ [formatter_generic]
39
+ format = %(levelname)-5.5s [%(name)s] %(message)s
40
+ datefmt = %H:%M:%S
chatkit/backend/alembic/env.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Alembic environment — async, schema-isolated to the ClassLens schema.
2
+
3
+ The DB connection and target schema come from application settings, so the same
4
+ migrations run against Supabase (schema=classlens) or local SQLite (schema=None).
5
+ Only the ClassLens schema is ever touched — other apps sharing the DB are ignored.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from logging.config import fileConfig
12
+
13
+ from alembic import context
14
+
15
+ from app.db import active_schema, get_engine
16
+ from app.models import Base # noqa: F401 (registers all tables on Base.metadata)
17
+
18
+ config = context.config
19
+ if config.config_file_name:
20
+ fileConfig(config.config_file_name)
21
+
22
+ target_metadata = Base.metadata
23
+ SCHEMA = active_schema()
24
+
25
+
26
+ def _include_object(obj, name, type_, reflected, compare_to):
27
+ """Never let autogenerate manage tables outside our schema."""
28
+ if type_ == "table":
29
+ return getattr(obj, "schema", None) == SCHEMA
30
+ return True
31
+
32
+
33
+ def _configure(connection):
34
+ context.configure(
35
+ connection=connection,
36
+ target_metadata=target_metadata,
37
+ version_table_schema=SCHEMA,
38
+ include_schemas=False,
39
+ include_object=_include_object,
40
+ compare_type=True,
41
+ )
42
+
43
+
44
+ def _run(connection):
45
+ _configure(connection)
46
+ with context.begin_transaction():
47
+ context.run_migrations()
48
+
49
+
50
+ async def run_async():
51
+ engine = get_engine()
52
+ async with engine.connect() as conn:
53
+ await conn.run_sync(_run)
54
+ await engine.dispose()
55
+
56
+
57
+ if context.is_offline_mode():
58
+ context.configure(
59
+ url=str(get_engine().url),
60
+ target_metadata=target_metadata,
61
+ version_table_schema=SCHEMA,
62
+ include_object=_include_object,
63
+ literal_binds=True,
64
+ dialect_opts={"paramstyle": "named"},
65
+ )
66
+ with context.begin_transaction():
67
+ context.run_migrations()
68
+ else:
69
+ asyncio.run(run_async())
chatkit/backend/alembic/script.py.mako ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+ """
7
+ from alembic import op
8
+ import sqlalchemy as sa
9
+ ${imports if imports else ""}
10
+
11
+ revision = ${repr(up_revision)}
12
+ down_revision = ${repr(down_revision)}
13
+ branch_labels = ${repr(branch_labels)}
14
+ depends_on = ${repr(depends_on)}
15
+
16
+
17
+ def upgrade() -> None:
18
+ ${upgrades if upgrades else "pass"}
19
+
20
+
21
+ def downgrade() -> None:
22
+ ${downgrades if downgrades else "pass"}
chatkit/backend/alembic/versions/0001_initial.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """initial ClassLens schema
2
+
3
+ Creates all ClassLens tables (teachers, students, quizzes, raw_files,
4
+ parsed_data, student_results, prompts, reports) in the dedicated schema.
5
+
6
+ Revision ID: 0001_initial
7
+ Revises:
8
+ Create Date: 2026-06-03
9
+ """
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+
13
+ from app.db import active_schema
14
+ from app.models import Base
15
+
16
+ revision = "0001_initial"
17
+ down_revision = None
18
+ branch_labels = None
19
+ depends_on = None
20
+
21
+
22
+ def upgrade() -> None:
23
+ bind = op.get_bind()
24
+ schema = active_schema()
25
+ if schema and bind.dialect.name == "postgresql":
26
+ op.execute(sa.text(f'CREATE SCHEMA IF NOT EXISTS "{schema}"'))
27
+ # Create every table defined on the models' metadata (single source of truth).
28
+ Base.metadata.create_all(bind)
29
+
30
+
31
+ def downgrade() -> None:
32
+ Base.metadata.drop_all(op.get_bind())
chatkit/backend/app/admin.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Admin-only API: analytics dashboard, user/session management, schema info.
2
+
3
+ All routes require an admin teacher (see auth.get_current_admin).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException
9
+ from pydantic import BaseModel
10
+
11
+ from .auth import get_current_admin
12
+ from .database import (
13
+ admin_list_sessions,
14
+ admin_list_students,
15
+ delete_session,
16
+ delete_user,
17
+ get_schema_version,
18
+ get_session,
19
+ get_stats,
20
+ get_student,
21
+ get_student_timeline,
22
+ list_all_users,
23
+ set_user_admin,
24
+ )
25
+
26
+ router = APIRouter(prefix="/api/admin", tags=["admin"])
27
+
28
+
29
+ @router.get("/stats")
30
+ async def admin_stats(_admin=Depends(get_current_admin)):
31
+ stats = await get_stats()
32
+ stats["schema_version"] = await get_schema_version()
33
+ return stats
34
+
35
+
36
+ @router.get("/users")
37
+ async def admin_users(_admin=Depends(get_current_admin)):
38
+ return {"users": await list_all_users()}
39
+
40
+
41
+ class SetAdminRequest(BaseModel):
42
+ is_admin: bool
43
+
44
+
45
+ @router.put("/users/{user_id}/admin")
46
+ async def admin_set_admin(user_id: int, body: SetAdminRequest, admin=Depends(get_current_admin)):
47
+ if user_id == admin["id"] and not body.is_admin:
48
+ raise HTTPException(status_code=400, detail="You cannot remove your own admin access")
49
+ await set_user_admin(user_id, body.is_admin)
50
+ return {"status": "ok", "user_id": user_id, "is_admin": body.is_admin}
51
+
52
+
53
+ @router.delete("/users/{user_id}")
54
+ async def admin_delete_user(user_id: int, admin=Depends(get_current_admin)):
55
+ if user_id == admin["id"]:
56
+ raise HTTPException(status_code=400, detail="You cannot delete your own account here")
57
+ await delete_user(user_id)
58
+ return {"status": "deleted"}
59
+
60
+
61
+ @router.get("/sessions")
62
+ async def admin_sessions(_admin=Depends(get_current_admin)):
63
+ return {"sessions": await admin_list_sessions()}
64
+
65
+
66
+ @router.delete("/sessions/{session_id}")
67
+ async def admin_delete_session(session_id: int, _admin=Depends(get_current_admin)):
68
+ if not await get_session(session_id):
69
+ raise HTTPException(status_code=404, detail="Session not found")
70
+ await delete_session(session_id)
71
+ return {"status": "deleted"}
72
+
73
+
74
+ @router.get("/students")
75
+ async def admin_students(_admin=Depends(get_current_admin)):
76
+ return {"students": await admin_list_students()}
77
+
78
+
79
+ @router.get("/students/{student_id}")
80
+ async def admin_student_detail(student_id: int, _admin=Depends(get_current_admin)):
81
+ student = await get_student(student_id)
82
+ if not student:
83
+ raise HTTPException(status_code=404, detail="Student not found")
84
+ return {"student": student, "timeline": await get_student_timeline(student_id)}
chatkit/backend/app/answer_grid.py CHANGED
@@ -215,6 +215,21 @@ def seed_from_parsed(
215
  )
216
 
217
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  def diff_student(grid: AnswerGrid, student_index: int) -> list[WrongAnswer]:
219
  """Return the student's wrong answers ordered by question number.
220
 
 
215
  )
216
 
217
 
218
+ def score_student(grid: AnswerGrid, student_index: int) -> tuple[int, int, float]:
219
+ """Return (graded_questions, correct_count, score_percent) for one student.
220
+
221
+ Only questions with a concrete official letter (not blank, not the "="
222
+ correct-marker) are graded. A student cell of "=" counts as correct.
223
+ """
224
+ graded = sum(
225
+ 1 for c in grid.official_answers if c is not None and c != _CORRECT_MARKER
226
+ )
227
+ wrong = len(diff_student(grid, student_index))
228
+ correct = graded - wrong
229
+ score = round(correct / graded * 100, 1) if graded else 0.0
230
+ return graded, correct, score
231
+
232
+
233
  def diff_student(grid: AnswerGrid, student_index: int) -> list[WrongAnswer]:
234
  """Return the student's wrong answers ordered by question number.
235
 
chatkit/backend/app/auth.py CHANGED
@@ -56,17 +56,28 @@ async def get_current_user(
56
  return user
57
 
58
 
59
- async def register_user(email: str, password: str) -> dict:
60
- """Register a new user. Returns user dict."""
 
 
61
  existing = await get_user_by_email(email)
62
  if existing:
63
  raise HTTPException(status_code=400, detail="Email already registered")
64
  hashed = hash_password(password)
65
- user_id = await create_user(email, hashed)
 
 
66
  user = await get_user_by_id(user_id)
67
  return user
68
 
69
 
 
 
 
 
 
 
 
70
  async def login_user(email: str, password: str) -> dict:
71
  """Validate credentials and return user dict + JWT token."""
72
  user = await get_user_by_email(email)
@@ -74,4 +85,16 @@ async def login_user(email: str, password: str) -> dict:
74
  raise HTTPException(status_code=401, detail="Invalid email or password")
75
  await update_last_login(user["id"])
76
  token = create_access_token({"sub": str(user["id"])})
77
- return {"token": token, "user": {"id": user["id"], "email": user["email"], "display_name": user["display_name"]}}
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  return user
57
 
58
 
59
+ async def register_user(
60
+ email: str, password: str, full_name: str = "", school: str = ""
61
+ ) -> dict:
62
+ """Register a new teacher. Returns user dict."""
63
  existing = await get_user_by_email(email)
64
  if existing:
65
  raise HTTPException(status_code=400, detail="Email already registered")
66
  hashed = hash_password(password)
67
+ user_id = await create_user(
68
+ email, hashed, display_name=full_name, full_name=full_name, school=school
69
+ )
70
  user = await get_user_by_id(user_id)
71
  return user
72
 
73
 
74
+ async def get_current_admin(user: dict = Depends(get_current_user)) -> dict:
75
+ """FastAPI dependency: require the authenticated user to be an admin."""
76
+ if not user.get("is_admin"):
77
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
78
+ return user
79
+
80
+
81
  async def login_user(email: str, password: str) -> dict:
82
  """Validate credentials and return user dict + JWT token."""
83
  user = await get_user_by_email(email)
 
85
  raise HTTPException(status_code=401, detail="Invalid email or password")
86
  await update_last_login(user["id"])
87
  token = create_access_token({"sub": str(user["id"])})
88
+ return {"token": token, "user": public_user(user)}
89
+
90
+
91
+ def public_user(user: dict) -> dict:
92
+ """User fields safe to return to the client (no password hash)."""
93
+ return {
94
+ "id": user["id"],
95
+ "email": user["email"],
96
+ "display_name": user.get("display_name", ""),
97
+ "full_name": user.get("full_name", ""),
98
+ "school": user.get("school", ""),
99
+ "is_admin": bool(user.get("is_admin")),
100
+ }
chatkit/backend/app/config.py CHANGED
@@ -28,7 +28,18 @@ class Settings(BaseModel):
28
  jwt_expire_minutes: int = 1440 # 24 hours
29
 
30
  # Database
31
- database_path: str = "" # auto-resolved if empty
 
 
 
 
 
 
 
 
 
 
 
32
 
33
  # Encryption key for sensitive data
34
  encryption_key: str = ""
@@ -53,6 +64,13 @@ def get_settings() -> Settings:
53
  jwt_algorithm=os.getenv("JWT_ALGORITHM", "HS256"),
54
  jwt_expire_minutes=int(os.getenv("JWT_EXPIRE_MINUTES", "1440")),
55
  database_path=os.getenv("DATABASE_PATH", ""),
 
 
 
 
 
 
 
56
  encryption_key=os.getenv("ENCRYPTION_KEY", ""),
57
  upload_dir=os.getenv("UPLOAD_DIR", ""),
58
  max_file_size_mb=int(os.getenv("MAX_FILE_SIZE_MB", "20")),
 
28
  jwt_expire_minutes: int = 1440 # 24 hours
29
 
30
  # Database
31
+ database_path: str = "" # legacy SQLite path; auto-resolved if empty
32
+ database_provider: str = "sqlite" # "supabase" | "sqlite"
33
+ db_schema: str = "classlens" # Postgres schema to isolate ClassLens tables
34
+
35
+ # Supabase
36
+ supabase_url: str = ""
37
+ supabase_service_role_key: str = ""
38
+ supabase_db_url: str = "" # postgresql://... (session pooler)
39
+ supabase_storage_bucket: str = "classlens-raw-files"
40
+
41
+ # Admin bootstrap: comma-separated emails promoted to admin on startup
42
+ admin_emails: str = ""
43
 
44
  # Encryption key for sensitive data
45
  encryption_key: str = ""
 
64
  jwt_algorithm=os.getenv("JWT_ALGORITHM", "HS256"),
65
  jwt_expire_minutes=int(os.getenv("JWT_EXPIRE_MINUTES", "1440")),
66
  database_path=os.getenv("DATABASE_PATH", ""),
67
+ database_provider=os.getenv("DATABASE_PROVIDER", "sqlite"),
68
+ db_schema=os.getenv("DB_SCHEMA", "classlens"),
69
+ supabase_url=os.getenv("SUPABASE_URL", ""),
70
+ supabase_service_role_key=os.getenv("SUPABASE_SERVICE_ROLE_KEY", ""),
71
+ supabase_db_url=os.getenv("SUPABASE_DB_URL", ""),
72
+ supabase_storage_bucket=os.getenv("SUPABASE_STORAGE_BUCKET", "classlens-raw-files"),
73
+ admin_emails=os.getenv("ADMIN_EMAILS", ""),
74
  encryption_key=os.getenv("ENCRYPTION_KEY", ""),
75
  upload_dir=os.getenv("UPLOAD_DIR", ""),
76
  max_file_size_mb=int(os.getenv("MAX_FILE_SIZE_MB", "20")),
chatkit/backend/app/database.py CHANGED
@@ -1,231 +1,263 @@
1
- """SQLite database for ClassLens - users, sessions, parsed data, prompts, reports."""
 
 
 
 
 
 
 
 
2
 
3
  from __future__ import annotations
4
 
5
- import aiosqlite
6
- import json
7
- from pathlib import Path
8
- from datetime import datetime
9
  from typing import Optional
10
 
 
 
11
  from .config import get_settings
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
- DATABASE_PATH = Path(__file__).parent.parent / "classlens.db"
14
 
15
 
16
- def _get_db_path() -> Path:
17
- settings = get_settings()
18
- if settings.database_path:
19
- return Path(settings.database_path)
20
- return DATABASE_PATH
 
 
 
 
 
 
 
 
 
21
 
 
 
 
22
 
23
  async def init_database():
24
- """Initialize the database with required tables."""
25
- db_path = _get_db_path()
26
- async with aiosqlite.connect(db_path) as db:
27
- await db.execute("""
28
- CREATE TABLE IF NOT EXISTS users (
29
- id INTEGER PRIMARY KEY AUTOINCREMENT,
30
- email TEXT UNIQUE NOT NULL,
31
- password_hash TEXT NOT NULL,
32
- display_name TEXT,
33
- created_at TEXT NOT NULL,
34
- last_login TEXT
35
- )
36
- """)
37
-
38
- await db.execute("""
39
- CREATE TABLE IF NOT EXISTS exam_sessions (
40
- id INTEGER PRIMARY KEY AUTOINCREMENT,
41
- user_id INTEGER NOT NULL,
42
- title TEXT DEFAULT 'Untitled Session',
43
- status TEXT DEFAULT 'draft',
44
- created_at TEXT NOT NULL,
45
- updated_at TEXT NOT NULL,
46
- FOREIGN KEY (user_id) REFERENCES users(id)
47
- )
48
- """)
49
-
50
- await db.execute("""
51
- CREATE TABLE IF NOT EXISTS parsed_data (
52
- id INTEGER PRIMARY KEY AUTOINCREMENT,
53
- session_id INTEGER NOT NULL,
54
- data_type TEXT NOT NULL,
55
- file_name TEXT,
56
- raw_text TEXT,
57
- structured_data TEXT NOT NULL,
58
- created_at TEXT NOT NULL,
59
- FOREIGN KEY (session_id) REFERENCES exam_sessions(id) ON DELETE CASCADE
60
- )
61
- """)
62
-
63
- await db.execute("""
64
- CREATE TABLE IF NOT EXISTS prompts (
65
- id INTEGER PRIMARY KEY AUTOINCREMENT,
66
- user_id INTEGER NOT NULL,
67
- name TEXT NOT NULL,
68
- content TEXT NOT NULL,
69
- is_default BOOLEAN DEFAULT 0,
70
- created_at TEXT NOT NULL,
71
- updated_at TEXT NOT NULL,
72
- FOREIGN KEY (user_id) REFERENCES users(id)
73
- )
74
- """)
75
-
76
- await db.execute("""
77
- CREATE TABLE IF NOT EXISTS reports (
78
- id INTEGER PRIMARY KEY AUTOINCREMENT,
79
- session_id INTEGER NOT NULL,
80
- prompt_id INTEGER,
81
- html_content TEXT NOT NULL,
82
- created_at TEXT NOT NULL,
83
- FOREIGN KEY (session_id) REFERENCES exam_sessions(id) ON DELETE CASCADE,
84
- FOREIGN KEY (prompt_id) REFERENCES prompts(id)
85
- )
86
- """)
87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  await db.commit()
89
 
90
 
91
- # ---- User CRUD ----
 
 
92
 
93
- async def create_user(email: str, password_hash: str, display_name: str = "") -> int:
94
- db_path = _get_db_path()
95
- now = datetime.utcnow().isoformat()
96
- async with aiosqlite.connect(db_path) as db:
97
- cursor = await db.execute(
98
- "INSERT INTO users (email, password_hash, display_name, created_at) VALUES (?, ?, ?, ?)",
99
- (email, password_hash, display_name or email.split("@")[0], now),
 
 
 
 
 
 
 
100
  )
 
101
  await db.commit()
102
- return cursor.lastrowid
 
103
 
104
 
105
  async def get_user_by_email(email: str) -> Optional[dict]:
106
- db_path = _get_db_path()
107
- async with aiosqlite.connect(db_path) as db:
108
- db.row_factory = aiosqlite.Row
109
- async with db.execute("SELECT * FROM users WHERE email = ?", (email,)) as cursor:
110
- row = await cursor.fetchone()
111
- return dict(row) if row else None
112
 
113
 
114
  async def get_user_by_id(user_id: int) -> Optional[dict]:
115
- db_path = _get_db_path()
116
- async with aiosqlite.connect(db_path) as db:
117
- db.row_factory = aiosqlite.Row
118
- async with db.execute("SELECT * FROM users WHERE id = ?", (user_id,)) as cursor:
119
- row = await cursor.fetchone()
120
- return dict(row) if row else None
121
 
122
 
123
  async def update_last_login(user_id: int):
124
- db_path = _get_db_path()
125
- now = datetime.utcnow().isoformat()
126
- async with aiosqlite.connect(db_path) as db:
127
- await db.execute("UPDATE users SET last_login = ? WHERE id = ?", (now, user_id))
 
 
 
 
 
 
 
 
128
  await db.commit()
129
 
130
 
131
- # ---- Session CRUD ----
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
 
133
  async def create_session(user_id: int, title: str = "Untitled Session") -> int:
134
- db_path = _get_db_path()
135
- now = datetime.utcnow().isoformat()
136
- async with aiosqlite.connect(db_path) as db:
137
- cursor = await db.execute(
138
- "INSERT INTO exam_sessions (user_id, title, status, created_at, updated_at) VALUES (?, ?, 'draft', ?, ?)",
139
- (user_id, title, now, now),
140
- )
141
  await db.commit()
142
- return cursor.lastrowid
 
143
 
144
 
145
  async def get_sessions(user_id: int) -> list[dict]:
146
- db_path = _get_db_path()
147
- async with aiosqlite.connect(db_path) as db:
148
- db.row_factory = aiosqlite.Row
149
- async with db.execute(
150
- "SELECT * FROM exam_sessions WHERE user_id = ? ORDER BY created_at DESC", (user_id,)
151
- ) as cursor:
152
- rows = await cursor.fetchall()
153
- return [dict(row) for row in rows]
154
 
155
 
156
  async def get_session(session_id: int) -> Optional[dict]:
157
- db_path = _get_db_path()
158
- async with aiosqlite.connect(db_path) as db:
159
- db.row_factory = aiosqlite.Row
160
- async with db.execute("SELECT * FROM exam_sessions WHERE id = ?", (session_id,)) as cursor:
161
- row = await cursor.fetchone()
162
- return dict(row) if row else None
 
163
 
164
 
165
  async def update_session_status(session_id: int, status: str):
166
- db_path = _get_db_path()
167
- now = datetime.utcnow().isoformat()
168
- async with aiosqlite.connect(db_path) as db:
169
  await db.execute(
170
- "UPDATE exam_sessions SET status = ?, updated_at = ? WHERE id = ?",
171
- (status, now, session_id),
172
  )
173
  await db.commit()
174
 
175
 
176
- # ---- Parsed Data CRUD ----
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
 
178
  async def save_parsed_data(
179
  session_id: int, data_type: str, file_name: str, raw_text: str, structured_data: dict
180
  ) -> int:
181
- db_path = _get_db_path()
182
- now = datetime.utcnow().isoformat()
183
- async with aiosqlite.connect(db_path) as db:
184
- cursor = await db.execute(
185
- "INSERT INTO parsed_data (session_id, data_type, file_name, raw_text, structured_data, created_at) VALUES (?, ?, ?, ?, ?, ?)",
186
- (session_id, data_type, file_name, raw_text, json.dumps(structured_data, ensure_ascii=False), now),
 
187
  )
 
188
  await db.commit()
189
- return cursor.lastrowid
 
190
 
191
 
192
  async def get_parsed_data(session_id: int) -> list[dict]:
193
- db_path = _get_db_path()
194
- async with aiosqlite.connect(db_path) as db:
195
- db.row_factory = aiosqlite.Row
196
- async with db.execute(
197
- "SELECT * FROM parsed_data WHERE session_id = ? ORDER BY data_type, created_at",
198
- (session_id,),
199
- ) as cursor:
200
- rows = await cursor.fetchall()
201
- result = []
202
- for row in rows:
203
- d = dict(row)
204
- d["structured_data"] = json.loads(d["structured_data"])
205
- result.append(d)
206
- return result
207
 
208
 
209
  async def delete_parsed_data(session_id: int, data_type: Optional[str] = None):
210
- db_path = _get_db_path()
211
- async with aiosqlite.connect(db_path) as db:
212
  if data_type:
213
- await db.execute(
214
- "DELETE FROM parsed_data WHERE session_id = ? AND data_type = ?",
215
- (session_id, data_type),
216
- )
217
- else:
218
- await db.execute("DELETE FROM parsed_data WHERE session_id = ?", (session_id,))
219
  await db.commit()
220
 
221
 
222
- # ---- Answer Grid CRUD ----
223
-
224
- ANSWER_GRID_DATA_TYPE = "answer_grid"
225
-
226
 
227
  async def save_answer_grid(session_id: int, grid_dict: dict) -> int:
228
- """Save confirmed answer grid, replacing any previous one for the session."""
229
  await delete_parsed_data(session_id, ANSWER_GRID_DATA_TYPE)
230
  return await save_parsed_data(
231
  session_id=session_id,
@@ -237,116 +269,330 @@ async def save_answer_grid(session_id: int, grid_dict: dict) -> int:
237
 
238
 
239
  async def get_answer_grid(session_id: int) -> Optional[dict]:
240
- """Return the saved answer grid dict or None if not confirmed."""
241
- db_path = _get_db_path()
242
- async with aiosqlite.connect(db_path) as db:
243
- db.row_factory = aiosqlite.Row
244
- async with db.execute(
245
- "SELECT structured_data FROM parsed_data WHERE session_id = ? AND data_type = ? ORDER BY created_at DESC LIMIT 1",
246
- (session_id, ANSWER_GRID_DATA_TYPE),
247
- ) as cursor:
248
- row = await cursor.fetchone()
249
- if not row:
250
- return None
251
- return json.loads(row["structured_data"])
252
-
253
-
254
- # ---- Prompt CRUD ----
255
 
256
- async def save_prompt(user_id: int, name: str, content: str, is_default: bool = False) -> int:
257
- db_path = _get_db_path()
258
- now = datetime.utcnow().isoformat()
259
- async with aiosqlite.connect(db_path) as db:
260
- cursor = await db.execute(
261
- "INSERT INTO prompts (user_id, name, content, is_default, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
262
- (user_id, name, content, is_default, now, now),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  await db.commit()
265
- return cursor.lastrowid
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
 
267
 
268
  async def get_prompts(user_id: int) -> list[dict]:
269
- """Get user's own prompts plus all default prompts."""
270
- db_path = _get_db_path()
271
- async with aiosqlite.connect(db_path) as db:
272
- db.row_factory = aiosqlite.Row
273
- async with db.execute(
274
- "SELECT * FROM prompts WHERE user_id = ? OR is_default = 1 ORDER BY is_default DESC, updated_at DESC",
275
- (user_id,),
276
- ) as cursor:
277
- rows = await cursor.fetchall()
278
- return [dict(row) for row in rows]
279
 
280
 
281
  async def get_all_prompts() -> list[dict]:
282
- """Get all prompts from all users (for the 'read others' dropdown)."""
283
- db_path = _get_db_path()
284
- async with aiosqlite.connect(db_path) as db:
285
- db.row_factory = aiosqlite.Row
286
- async with db.execute(
287
- "SELECT p.*, u.email as author_email FROM prompts p JOIN users u ON p.user_id = u.id ORDER BY p.updated_at DESC"
288
- ) as cursor:
289
- rows = await cursor.fetchall()
290
- return [dict(row) for row in rows]
 
 
 
291
 
292
 
293
  async def get_prompt(prompt_id: int) -> Optional[dict]:
294
- db_path = _get_db_path()
295
- async with aiosqlite.connect(db_path) as db:
296
- db.row_factory = aiosqlite.Row
297
- async with db.execute("SELECT * FROM prompts WHERE id = ?", (prompt_id,)) as cursor:
298
- row = await cursor.fetchone()
299
- return dict(row) if row else None
 
300
 
301
 
302
  async def update_prompt(prompt_id: int, name: str, content: str):
303
- db_path = _get_db_path()
304
- now = datetime.utcnow().isoformat()
305
- async with aiosqlite.connect(db_path) as db:
306
  await db.execute(
307
- "UPDATE prompts SET name = ?, content = ?, updated_at = ? WHERE id = ?",
308
- (name, content, now, prompt_id),
 
309
  )
310
  await db.commit()
311
 
312
 
313
  async def delete_prompt(prompt_id: int):
314
- db_path = _get_db_path()
315
- async with aiosqlite.connect(db_path) as db:
316
- await db.execute("DELETE FROM prompts WHERE id = ?", (prompt_id,))
317
  await db.commit()
318
 
319
 
320
- # ---- Report CRUD ----
 
 
321
 
322
- async def save_report(session_id: int, prompt_id: Optional[int], html_content: str) -> int:
323
- db_path = _get_db_path()
324
- now = datetime.utcnow().isoformat()
325
- async with aiosqlite.connect(db_path) as db:
326
- cursor = await db.execute(
327
- "INSERT INTO reports (session_id, prompt_id, html_content, created_at) VALUES (?, ?, ?, ?)",
328
- (session_id, prompt_id, html_content, now),
 
 
 
 
 
 
 
329
  )
 
330
  await db.commit()
331
- return cursor.lastrowid
 
332
 
333
 
334
  async def get_reports(session_id: int) -> list[dict]:
335
- db_path = _get_db_path()
336
- async with aiosqlite.connect(db_path) as db:
337
- db.row_factory = aiosqlite.Row
338
- async with db.execute(
339
- "SELECT * FROM reports WHERE session_id = ? ORDER BY created_at DESC",
340
- (session_id,),
341
- ) as cursor:
342
- rows = await cursor.fetchall()
343
- return [dict(row) for row in rows]
344
 
345
 
346
  async def get_report(report_id: int) -> Optional[dict]:
347
- db_path = _get_db_path()
348
- async with aiosqlite.connect(db_path) as db:
349
- db.row_factory = aiosqlite.Row
350
- async with db.execute("SELECT * FROM reports WHERE id = ?", (report_id,)) as cursor:
351
- row = await cursor.fetchone()
352
- return dict(row) if row else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Data-access layer for ClassLens, backed by SQLAlchemy async (Supabase Postgres).
2
+
3
+ Public function names/signatures are kept stable for existing callers:
4
+ - "session" in the API == a row in the ``quizzes`` table (a quiz/exam session)
5
+ - "user" == a row in the ``teachers`` table
6
+
7
+ New capabilities (students as first-class entities, per-student results, and
8
+ analytics) are added alongside the legacy CRUD helpers.
9
+ """
10
 
11
  from __future__ import annotations
12
 
13
+ import re
14
+ import unicodedata
15
+ from datetime import datetime, timezone
 
16
  from typing import Optional
17
 
18
+ from sqlalchemy import delete, func, select, update
19
+
20
  from .config import get_settings
21
+ from .db import get_engine, get_sessionmaker
22
+ from .models import (
23
+ Base,
24
+ ParsedData,
25
+ Prompt,
26
+ Quiz,
27
+ RawFile,
28
+ Report,
29
+ Student,
30
+ StudentResult,
31
+ Teacher,
32
+ )
33
 
34
+ ANSWER_GRID_DATA_TYPE = "answer_grid"
35
 
36
 
37
+ def _now() -> datetime:
38
+ return datetime.now(timezone.utc)
39
+
40
+
41
+ def _row(obj) -> dict:
42
+ """Serialize an ORM object to a plain dict of its columns."""
43
+ return {c.key: getattr(obj, c.key) for c in obj.__table__.columns}
44
+
45
+
46
+ def normalize_name(name: str) -> str:
47
+ """Canonical key for matching the same student across quizzes."""
48
+ s = unicodedata.normalize("NFKC", (name or "").strip()).casefold()
49
+ return re.sub(r"\s+", " ", s)
50
+
51
 
52
+ # =============================================================================
53
+ # Schema bootstrap
54
+ # =============================================================================
55
 
56
  async def init_database():
57
+ """Ensure schema exists and bootstrap admins.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
+ On Supabase, tables are managed by Alembic migrations; here we only create
60
+ tables for the SQLite dev/test fallback. Admin bootstrap runs for both.
61
+ """
62
+ settings = get_settings()
63
+
64
+ if settings.database_provider != "supabase":
65
+ async with get_engine().begin() as conn:
66
+ await conn.run_sync(Base.metadata.create_all)
67
+
68
+ await _bootstrap_admins()
69
+
70
+
71
+ async def _bootstrap_admins():
72
+ settings = get_settings()
73
+ emails = [e.strip().lower() for e in (settings.admin_emails or "").split(",") if e.strip()]
74
+ if not emails:
75
+ return
76
+ async with get_sessionmaker()() as db:
77
+ await db.execute(
78
+ update(Teacher).where(func.lower(Teacher.email).in_(emails)).values(is_admin=True)
79
+ )
80
  await db.commit()
81
 
82
 
83
+ # =============================================================================
84
+ # Teachers (users)
85
+ # =============================================================================
86
 
87
+ async def create_user(
88
+ email: str,
89
+ password_hash: str,
90
+ display_name: str = "",
91
+ full_name: str = "",
92
+ school: str = "",
93
+ ) -> int:
94
+ async with get_sessionmaker()() as db:
95
+ teacher = Teacher(
96
+ email=email,
97
+ password_hash=password_hash,
98
+ display_name=display_name or email.split("@")[0],
99
+ full_name=full_name,
100
+ school=school,
101
  )
102
+ db.add(teacher)
103
  await db.commit()
104
+ await db.refresh(teacher)
105
+ return teacher.id
106
 
107
 
108
  async def get_user_by_email(email: str) -> Optional[dict]:
109
+ async with get_sessionmaker()() as db:
110
+ res = await db.execute(select(Teacher).where(Teacher.email == email))
111
+ teacher = res.scalar_one_or_none()
112
+ return _row(teacher) if teacher else None
 
 
113
 
114
 
115
  async def get_user_by_id(user_id: int) -> Optional[dict]:
116
+ async with get_sessionmaker()() as db:
117
+ teacher = await db.get(Teacher, user_id)
118
+ return _row(teacher) if teacher else None
 
 
 
119
 
120
 
121
  async def update_last_login(user_id: int):
122
+ async with get_sessionmaker()() as db:
123
+ await db.execute(
124
+ update(Teacher).where(Teacher.id == user_id).values(last_login=_now())
125
+ )
126
+ await db.commit()
127
+
128
+
129
+ async def set_user_admin(user_id: int, is_admin: bool):
130
+ async with get_sessionmaker()() as db:
131
+ await db.execute(
132
+ update(Teacher).where(Teacher.id == user_id).values(is_admin=is_admin)
133
+ )
134
  await db.commit()
135
 
136
 
137
+ async def list_all_users() -> list[dict]:
138
+ """Admin: all teachers (without password hashes), with per-teacher counts."""
139
+ async with get_sessionmaker()() as db:
140
+ res = await db.execute(select(Teacher).order_by(Teacher.created_at.desc()))
141
+ teachers = res.scalars().all()
142
+ out = []
143
+ for t in teachers:
144
+ d = _row(t)
145
+ d.pop("password_hash", None)
146
+ d["quiz_count"] = (
147
+ await db.execute(select(func.count(Quiz.id)).where(Quiz.teacher_id == t.id))
148
+ ).scalar_one()
149
+ d["student_count"] = (
150
+ await db.execute(select(func.count(Student.id)).where(Student.teacher_id == t.id))
151
+ ).scalar_one()
152
+ out.append(d)
153
+ return out
154
+
155
+
156
+ async def delete_user(user_id: int):
157
+ async with get_sessionmaker()() as db:
158
+ await db.execute(delete(Teacher).where(Teacher.id == user_id))
159
+ await db.commit()
160
+
161
+
162
+ # =============================================================================
163
+ # Quizzes (sessions)
164
+ # =============================================================================
165
 
166
  async def create_session(user_id: int, title: str = "Untitled Session") -> int:
167
+ async with get_sessionmaker()() as db:
168
+ quiz = Quiz(teacher_id=user_id, title=title, status="draft")
169
+ db.add(quiz)
 
 
 
 
170
  await db.commit()
171
+ await db.refresh(quiz)
172
+ return quiz.id
173
 
174
 
175
  async def get_sessions(user_id: int) -> list[dict]:
176
+ async with get_sessionmaker()() as db:
177
+ res = await db.execute(
178
+ select(Quiz).where(Quiz.teacher_id == user_id).order_by(Quiz.created_at.desc())
179
+ )
180
+ return [_row(q) for q in res.scalars().all()]
 
 
 
181
 
182
 
183
  async def get_session(session_id: int) -> Optional[dict]:
184
+ async with get_sessionmaker()() as db:
185
+ quiz = await db.get(Quiz, session_id)
186
+ if not quiz:
187
+ return None
188
+ d = _row(quiz)
189
+ d["user_id"] = quiz.teacher_id # back-compat key for existing callers
190
+ return d
191
 
192
 
193
  async def update_session_status(session_id: int, status: str):
194
+ async with get_sessionmaker()() as db:
 
 
195
  await db.execute(
196
+ update(Quiz).where(Quiz.id == session_id).values(status=status, updated_at=_now())
 
197
  )
198
  await db.commit()
199
 
200
 
201
+ async def update_session_meta(session_id: int, title: Optional[str] = None, subject: Optional[str] = None):
202
+ values: dict = {"updated_at": _now()}
203
+ if title is not None:
204
+ values["title"] = title
205
+ if subject is not None:
206
+ values["subject"] = subject
207
+ async with get_sessionmaker()() as db:
208
+ await db.execute(update(Quiz).where(Quiz.id == session_id).values(**values))
209
+ await db.commit()
210
+
211
+
212
+ async def delete_session(session_id: int):
213
+ async with get_sessionmaker()() as db:
214
+ await db.execute(delete(Quiz).where(Quiz.id == session_id))
215
+ await db.commit()
216
+
217
+
218
+ # =============================================================================
219
+ # Parsed data
220
+ # =============================================================================
221
 
222
  async def save_parsed_data(
223
  session_id: int, data_type: str, file_name: str, raw_text: str, structured_data: dict
224
  ) -> int:
225
+ async with get_sessionmaker()() as db:
226
+ row = ParsedData(
227
+ quiz_id=session_id,
228
+ data_type=data_type,
229
+ file_name=file_name or "",
230
+ raw_text=raw_text or "",
231
+ structured_data=structured_data,
232
  )
233
+ db.add(row)
234
  await db.commit()
235
+ await db.refresh(row)
236
+ return row.id
237
 
238
 
239
  async def get_parsed_data(session_id: int) -> list[dict]:
240
+ async with get_sessionmaker()() as db:
241
+ res = await db.execute(
242
+ select(ParsedData)
243
+ .where(ParsedData.quiz_id == session_id)
244
+ .order_by(ParsedData.data_type, ParsedData.created_at)
245
+ )
246
+ return [_row(p) for p in res.scalars().all()]
 
 
 
 
 
 
 
247
 
248
 
249
  async def delete_parsed_data(session_id: int, data_type: Optional[str] = None):
250
+ async with get_sessionmaker()() as db:
251
+ stmt = delete(ParsedData).where(ParsedData.quiz_id == session_id)
252
  if data_type:
253
+ stmt = stmt.where(ParsedData.data_type == data_type)
254
+ await db.execute(stmt)
 
 
 
 
255
  await db.commit()
256
 
257
 
258
+ # ---- Answer grid (stored as a parsed_data row) ----
 
 
 
259
 
260
  async def save_answer_grid(session_id: int, grid_dict: dict) -> int:
 
261
  await delete_parsed_data(session_id, ANSWER_GRID_DATA_TYPE)
262
  return await save_parsed_data(
263
  session_id=session_id,
 
269
 
270
 
271
  async def get_answer_grid(session_id: int) -> Optional[dict]:
272
+ async with get_sessionmaker()() as db:
273
+ res = await db.execute(
274
+ select(ParsedData.structured_data)
275
+ .where(ParsedData.quiz_id == session_id)
276
+ .where(ParsedData.data_type == ANSWER_GRID_DATA_TYPE)
277
+ .order_by(ParsedData.created_at.desc())
278
+ .limit(1)
279
+ )
280
+ row = res.scalar_one_or_none()
281
+ return row if row else None
 
 
 
 
 
282
 
283
+
284
+ # =============================================================================
285
+ # Raw files (metadata; bytes live in Supabase Storage)
286
+ # =============================================================================
287
+
288
+ async def save_raw_file(
289
+ session_id: int,
290
+ data_type: str,
291
+ file_name: str,
292
+ content_type: str,
293
+ size_bytes: int,
294
+ storage_path: str,
295
+ ) -> int:
296
+ async with get_sessionmaker()() as db:
297
+ row = RawFile(
298
+ quiz_id=session_id,
299
+ data_type=data_type,
300
+ file_name=file_name,
301
+ content_type=content_type,
302
+ size_bytes=size_bytes,
303
+ storage_path=storage_path,
304
+ )
305
+ db.add(row)
306
+ await db.commit()
307
+ await db.refresh(row)
308
+ return row.id
309
+
310
+
311
+ async def get_raw_files(session_id: int) -> list[dict]:
312
+ async with get_sessionmaker()() as db:
313
+ res = await db.execute(
314
+ select(RawFile).where(RawFile.quiz_id == session_id).order_by(RawFile.created_at)
315
  )
316
+ return [_row(f) for f in res.scalars().all()]
317
+
318
+
319
+ # =============================================================================
320
+ # Students (first-class, matched across quizzes by normalized name)
321
+ # =============================================================================
322
+
323
+ async def match_or_create_student(
324
+ teacher_id: int, name: str, external_id: Optional[str] = None
325
+ ) -> int:
326
+ """Return the student id for this teacher, creating the row if new."""
327
+ norm = normalize_name(name)
328
+ async with get_sessionmaker()() as db:
329
+ res = await db.execute(
330
+ select(Student)
331
+ .where(Student.teacher_id == teacher_id)
332
+ .where(Student.normalized_name == norm)
333
+ )
334
+ student = res.scalar_one_or_none()
335
+ if student:
336
+ if external_id and not student.external_id:
337
+ student.external_id = external_id
338
+ await db.commit()
339
+ return student.id
340
+ student = Student(
341
+ teacher_id=teacher_id, name=name, normalized_name=norm, external_id=external_id
342
+ )
343
+ db.add(student)
344
  await db.commit()
345
+ await db.refresh(student)
346
+ return student.id
347
+
348
+
349
+ async def get_students(teacher_id: int) -> list[dict]:
350
+ async with get_sessionmaker()() as db:
351
+ res = await db.execute(
352
+ select(Student).where(Student.teacher_id == teacher_id).order_by(Student.name)
353
+ )
354
+ return [_row(s) for s in res.scalars().all()]
355
+
356
+
357
+ async def get_student(student_id: int) -> Optional[dict]:
358
+ async with get_sessionmaker()() as db:
359
+ student = await db.get(Student, student_id)
360
+ return _row(student) if student else None
361
+
362
+
363
+ async def save_student_result(
364
+ quiz_id: int,
365
+ student_id: int,
366
+ answers: dict,
367
+ total_questions: int,
368
+ correct_count: int,
369
+ score: float,
370
+ ) -> int:
371
+ """Upsert one student's result for a quiz (unique per quiz+student)."""
372
+ async with get_sessionmaker()() as db:
373
+ res = await db.execute(
374
+ select(StudentResult)
375
+ .where(StudentResult.quiz_id == quiz_id)
376
+ .where(StudentResult.student_id == student_id)
377
+ )
378
+ existing = res.scalar_one_or_none()
379
+ if existing:
380
+ existing.answers = answers
381
+ existing.total_questions = total_questions
382
+ existing.correct_count = correct_count
383
+ existing.score = score
384
+ await db.commit()
385
+ return existing.id
386
+ row = StudentResult(
387
+ quiz_id=quiz_id,
388
+ student_id=student_id,
389
+ answers=answers,
390
+ total_questions=total_questions,
391
+ correct_count=correct_count,
392
+ score=score,
393
+ )
394
+ db.add(row)
395
+ await db.commit()
396
+ await db.refresh(row)
397
+ return row.id
398
+
399
+
400
+ async def get_student_timeline(student_id: int) -> list[dict]:
401
+ """One student's results across all quizzes, oldest first — the trajectory."""
402
+ async with get_sessionmaker()() as db:
403
+ res = await db.execute(
404
+ select(StudentResult, Quiz.title, Quiz.subject, Quiz.created_at)
405
+ .join(Quiz, StudentResult.quiz_id == Quiz.id)
406
+ .where(StudentResult.student_id == student_id)
407
+ .order_by(Quiz.created_at)
408
+ )
409
+ out = []
410
+ for sr, title, subject, created in res.all():
411
+ d = _row(sr)
412
+ d["quiz_title"] = title
413
+ d["quiz_subject"] = subject
414
+ d["quiz_created_at"] = created
415
+ out.append(d)
416
+ return out
417
+
418
+
419
+ # =============================================================================
420
+ # Prompts
421
+ # =============================================================================
422
+
423
+ async def save_prompt(user_id: int, name: str, content: str, is_default: bool = False) -> int:
424
+ async with get_sessionmaker()() as db:
425
+ row = Prompt(teacher_id=user_id, name=name, content=content, is_default=is_default)
426
+ db.add(row)
427
+ await db.commit()
428
+ await db.refresh(row)
429
+ return row.id
430
 
431
 
432
  async def get_prompts(user_id: int) -> list[dict]:
433
+ async with get_sessionmaker()() as db:
434
+ res = await db.execute(
435
+ select(Prompt)
436
+ .where((Prompt.teacher_id == user_id) | (Prompt.is_default.is_(True)))
437
+ .order_by(Prompt.is_default.desc(), Prompt.updated_at.desc())
438
+ )
439
+ return [_row(p) for p in res.scalars().all()]
 
 
 
440
 
441
 
442
  async def get_all_prompts() -> list[dict]:
443
+ async with get_sessionmaker()() as db:
444
+ res = await db.execute(
445
+ select(Prompt, Teacher.email)
446
+ .join(Teacher, Prompt.teacher_id == Teacher.id)
447
+ .order_by(Prompt.updated_at.desc())
448
+ )
449
+ out = []
450
+ for p, email in res.all():
451
+ d = _row(p)
452
+ d["author_email"] = email
453
+ out.append(d)
454
+ return out
455
 
456
 
457
  async def get_prompt(prompt_id: int) -> Optional[dict]:
458
+ async with get_sessionmaker()() as db:
459
+ p = await db.get(Prompt, prompt_id)
460
+ if not p:
461
+ return None
462
+ d = _row(p)
463
+ d["user_id"] = p.teacher_id # back-compat key
464
+ return d
465
 
466
 
467
  async def update_prompt(prompt_id: int, name: str, content: str):
468
+ async with get_sessionmaker()() as db:
 
 
469
  await db.execute(
470
+ update(Prompt)
471
+ .where(Prompt.id == prompt_id)
472
+ .values(name=name, content=content, updated_at=_now())
473
  )
474
  await db.commit()
475
 
476
 
477
  async def delete_prompt(prompt_id: int):
478
+ async with get_sessionmaker()() as db:
479
+ await db.execute(delete(Prompt).where(Prompt.id == prompt_id))
 
480
  await db.commit()
481
 
482
 
483
+ # =============================================================================
484
+ # Reports
485
+ # =============================================================================
486
 
487
+ async def save_report(
488
+ session_id: int,
489
+ prompt_id: Optional[int],
490
+ html_content: str,
491
+ student_id: Optional[int] = None,
492
+ model: str = "",
493
+ ) -> int:
494
+ async with get_sessionmaker()() as db:
495
+ row = Report(
496
+ quiz_id=session_id,
497
+ prompt_id=prompt_id,
498
+ student_id=student_id,
499
+ html_content=html_content,
500
+ model=model,
501
  )
502
+ db.add(row)
503
  await db.commit()
504
+ await db.refresh(row)
505
+ return row.id
506
 
507
 
508
  async def get_reports(session_id: int) -> list[dict]:
509
+ async with get_sessionmaker()() as db:
510
+ res = await db.execute(
511
+ select(Report).where(Report.quiz_id == session_id).order_by(Report.created_at.desc())
512
+ )
513
+ return [_row(r) for r in res.scalars().all()]
 
 
 
 
514
 
515
 
516
  async def get_report(report_id: int) -> Optional[dict]:
517
+ async with get_sessionmaker()() as db:
518
+ r = await db.get(Report, report_id)
519
+ return _row(r) if r else None
520
+
521
+
522
+ # =============================================================================
523
+ # Analytics (admin dashboard)
524
+ # =============================================================================
525
+
526
+ async def get_stats() -> dict:
527
+ async with get_sessionmaker()() as db:
528
+ async def count(model):
529
+ return (await db.execute(select(func.count(model.id)))).scalar_one()
530
+
531
+ return {
532
+ "teachers": await count(Teacher),
533
+ "students": await count(Student),
534
+ "quizzes": await count(Quiz),
535
+ "reports": await count(Report),
536
+ "student_results": await count(StudentResult),
537
+ "prompts": await count(Prompt),
538
+ }
539
+
540
+
541
+ async def admin_list_students(limit: int = 500) -> list[dict]:
542
+ """All students across teachers, with quiz count + average score (admin)."""
543
+ async with get_sessionmaker()() as db:
544
+ res = await db.execute(
545
+ select(Student, Teacher.email).join(Teacher, Student.teacher_id == Teacher.id).order_by(Student.name).limit(limit)
546
+ )
547
+ out = []
548
+ for s, email in res.all():
549
+ d = _row(s)
550
+ d["teacher_email"] = email
551
+ agg = (
552
+ await db.execute(
553
+ select(func.count(StudentResult.id), func.avg(StudentResult.score)).where(
554
+ StudentResult.student_id == s.id
555
+ )
556
+ )
557
+ ).one()
558
+ d["result_count"] = agg[0]
559
+ d["avg_score"] = round(float(agg[1]), 1) if agg[1] is not None else None
560
+ out.append(d)
561
+ return out
562
+
563
+
564
+ async def get_schema_version() -> Optional[str]:
565
+ """Current Alembic migration revision applied to the DB."""
566
+ from sqlalchemy import text
567
+
568
+ from .db import active_schema
569
+
570
+ schema = active_schema()
571
+ table = f'"{schema}".alembic_version' if schema else "alembic_version"
572
+ async with get_sessionmaker()() as db:
573
+ try:
574
+ res = await db.execute(text(f"SELECT version_num FROM {table} LIMIT 1"))
575
+ row = res.first()
576
+ return row[0] if row else None
577
+ except Exception:
578
+ return None
579
+
580
+
581
+ async def admin_list_sessions(limit: int = 200) -> list[dict]:
582
+ """All quizzes across teachers, with author email + counts (admin)."""
583
+ async with get_sessionmaker()() as db:
584
+ res = await db.execute(
585
+ select(Quiz, Teacher.email)
586
+ .join(Teacher, Quiz.teacher_id == Teacher.id)
587
+ .order_by(Quiz.created_at.desc())
588
+ .limit(limit)
589
+ )
590
+ out = []
591
+ for q, email in res.all():
592
+ d = _row(q)
593
+ d["teacher_email"] = email
594
+ d["report_count"] = (
595
+ await db.execute(select(func.count(Report.id)).where(Report.quiz_id == q.id))
596
+ ).scalar_one()
597
+ out.append(d)
598
+ return out
chatkit/backend/app/db.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Async SQLAlchemy engine + session management for ClassLens.
2
+
3
+ Supports two providers (DATABASE_PROVIDER):
4
+ - "supabase": Postgres via asyncpg, tables isolated in a dedicated schema
5
+ - "sqlite": local aiosqlite file (dev/tests; schema flattened to None)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import ssl
12
+ from functools import lru_cache
13
+ from pathlib import Path
14
+ from typing import AsyncIterator, Optional
15
+
16
+ from sqlalchemy.engine import URL, make_url
17
+ from sqlalchemy.ext.asyncio import (
18
+ AsyncEngine,
19
+ AsyncSession,
20
+ async_sessionmaker,
21
+ create_async_engine,
22
+ )
23
+
24
+ from .config import get_settings
25
+
26
+
27
+ def _is_supabase() -> bool:
28
+ s = get_settings()
29
+ return s.database_provider == "supabase" and bool(s.supabase_db_url)
30
+
31
+
32
+ def active_schema() -> Optional[str]:
33
+ """The Postgres schema for our tables, or None for SQLite (no schemas)."""
34
+ if _is_supabase():
35
+ return get_settings().db_schema or None
36
+ return None
37
+
38
+
39
+ def assert_production_db_config() -> None:
40
+ """Fail loud rather than silently running on ephemeral SQLite.
41
+
42
+ The container FS on HF Spaces is ephemeral, so booting on SQLite means all
43
+ data is lost on every sleep/rebuild. Refuse to start in those cases.
44
+ """
45
+ s = get_settings()
46
+ if s.database_provider == "supabase" and not s.supabase_db_url:
47
+ raise RuntimeError(
48
+ "DATABASE_PROVIDER=supabase but SUPABASE_DB_URL is empty — refusing to "
49
+ "start on ephemeral SQLite. Set the SUPABASE_DB_URL secret."
50
+ )
51
+ # On a deployed HF Space (SPACE_ID is injected by the platform), require a
52
+ # real Postgres DB so a missing/typo'd provider can't silently degrade.
53
+ if os.getenv("SPACE_ID") and not _is_supabase():
54
+ raise RuntimeError(
55
+ "Running on a Hugging Face Space without Supabase configured — refusing "
56
+ "to start on ephemeral SQLite. Set DATABASE_PROVIDER=supabase and "
57
+ "SUPABASE_DB_URL."
58
+ )
59
+
60
+
61
+ def _build_url() -> URL:
62
+ s = get_settings()
63
+ if _is_supabase():
64
+ # make_url keeps the raw password (handles '*' etc.); just swap the driver.
65
+ return make_url(s.supabase_db_url).set(drivername="postgresql+asyncpg")
66
+ # SQLite fallback
67
+ path = s.database_path or str(Path(__file__).parent.parent / "classlens.db")
68
+ return make_url(f"sqlite+aiosqlite:///{path}")
69
+
70
+
71
+ def _connect_args() -> dict:
72
+ if _is_supabase():
73
+ # Encrypt but don't verify the CA chain (libpq sslmode=require semantics).
74
+ # Supabase's pooler presents a valid cert in prod, but TLS-intercepting
75
+ # proxies (corp egress) break chain verification — this works in both.
76
+ ctx = ssl.create_default_context()
77
+ ctx.check_hostname = False
78
+ ctx.verify_mode = ssl.CERT_NONE
79
+ return {
80
+ "ssl": ctx,
81
+ # Supabase runs pgbouncer; disable asyncpg's prepared-statement cache
82
+ # so the same code works on both session (5432) and txn (6543) poolers.
83
+ "statement_cache_size": 0,
84
+ }
85
+ return {}
86
+
87
+
88
+ @lru_cache
89
+ def get_engine() -> AsyncEngine:
90
+ kwargs: dict = {"pool_pre_ping": True, "future": True}
91
+ if _is_supabase():
92
+ kwargs.update(pool_size=5, max_overflow=5, connect_args=_connect_args())
93
+ return create_async_engine(_build_url(), **kwargs)
94
+
95
+
96
+ @lru_cache
97
+ def get_sessionmaker() -> async_sessionmaker[AsyncSession]:
98
+ return async_sessionmaker(get_engine(), expire_on_commit=False, class_=AsyncSession)
99
+
100
+
101
+ async def get_session() -> AsyncIterator[AsyncSession]:
102
+ """FastAPI dependency: yields a session, commits on success, rolls back on error."""
103
+ maker = get_sessionmaker()
104
+ async with maker() as session:
105
+ try:
106
+ yield session
107
+ await session.commit()
108
+ except Exception:
109
+ await session.rollback()
110
+ raise
chatkit/backend/app/main.py CHANGED
@@ -8,7 +8,7 @@ from typing import Optional
8
 
9
  from fastapi import FastAPI, Depends, File, Form, UploadFile, HTTPException
10
  from fastapi.middleware.cors import CORSMiddleware
11
- from fastapi.responses import JSONResponse, Response, FileResponse
12
  from fastapi.staticfiles import StaticFiles
13
  from pydantic import BaseModel
14
 
@@ -18,6 +18,8 @@ from .database import (
18
  get_sessions,
19
  get_session,
20
  update_session_status,
 
 
21
  get_parsed_data,
22
  save_prompt,
23
  get_prompts,
@@ -31,11 +33,22 @@ from .database import (
31
  save_answer_grid,
32
  get_answer_grid,
33
  delete_parsed_data,
 
 
 
34
  ANSWER_GRID_DATA_TYPE,
35
  )
36
- from .auth import get_current_user, register_user, login_user
 
 
 
37
  from .file_processor import process_uploaded_files
38
- from .answer_grid import from_dict as grid_from_dict, seed_from_parsed, to_dict as grid_to_dict
 
 
 
 
 
39
  from .parsers import AUTO, list_parsers
40
  from .report_generator import generate_student_report, build_student_markdown
41
 
@@ -45,6 +58,7 @@ STATIC_DIR = Path(__file__).parent.parent / "static"
45
  @asynccontextmanager
46
  async def lifespan(app: FastAPI):
47
  """Initialize database on startup."""
 
48
  await init_database()
49
  yield
50
 
@@ -64,6 +78,8 @@ app.add_middleware(
64
  allow_headers=["*"],
65
  )
66
 
 
 
67
 
68
  # =============================================================================
69
  # Auth Endpoints
@@ -74,12 +90,19 @@ class AuthRequest(BaseModel):
74
  password: str
75
 
76
 
 
 
 
 
 
 
 
77
  @app.post("/api/auth/register")
78
- async def api_register(body: AuthRequest):
79
- user = await register_user(body.email, body.password)
80
  from .auth import create_access_token
81
  token = create_access_token({"sub": str(user["id"])})
82
- return {"token": token, "user": {"id": user["id"], "email": user["email"], "display_name": user["display_name"]}}
83
 
84
 
85
  @app.post("/api/auth/login")
@@ -90,7 +113,7 @@ async def api_login(body: AuthRequest):
90
 
91
  @app.get("/api/auth/me")
92
  async def api_me(user=Depends(get_current_user)):
93
- return {"id": user["id"], "email": user["email"], "display_name": user["display_name"]}
94
 
95
 
96
  # =============================================================================
@@ -122,6 +145,27 @@ async def api_get_session(session_id: int, user=Depends(get_current_user)):
122
  return session
123
 
124
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  # =============================================================================
126
  # File Upload & Processing Endpoints
127
  # =============================================================================
@@ -153,6 +197,26 @@ async def api_upload_files(
153
  detail="data_type must be 'questions', 'answers', 'student_answers', or 'teacher_answers'",
154
  )
155
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  try:
157
  structured = await process_uploaded_files(
158
  files,
@@ -369,13 +433,62 @@ async def api_generate_student_report(
369
 
370
  try:
371
  html = await generate_student_report(grid, body.student_index, body.model)
372
- report_id = await save_report(session_id, None, html)
373
  student_name = grid.students[body.student_index].name
374
- return {"report_id": report_id, "html_content": html, "student_name": student_name}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
  except Exception as e:
376
  raise HTTPException(status_code=500, detail=f"Report generation failed: {str(e)}")
377
 
378
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
379
  @app.get("/api/sessions/{session_id}/reports")
380
  async def api_list_reports(session_id: int, user=Depends(get_current_user)):
381
  session = await get_session(session_id)
 
8
 
9
  from fastapi import FastAPI, Depends, File, Form, UploadFile, HTTPException
10
  from fastapi.middleware.cors import CORSMiddleware
11
+ from fastapi.responses import Response, FileResponse
12
  from fastapi.staticfiles import StaticFiles
13
  from pydantic import BaseModel
14
 
 
18
  get_sessions,
19
  get_session,
20
  update_session_status,
21
+ update_session_meta,
22
+ delete_session,
23
  get_parsed_data,
24
  save_prompt,
25
  get_prompts,
 
33
  save_answer_grid,
34
  get_answer_grid,
35
  delete_parsed_data,
36
+ match_or_create_student,
37
+ save_student_result,
38
+ save_raw_file,
39
  ANSWER_GRID_DATA_TYPE,
40
  )
41
+ from .storage import upload_raw_file
42
+ from .auth import get_current_user, register_user, login_user, public_user
43
+ from .admin import router as admin_router
44
+ from .db import assert_production_db_config
45
  from .file_processor import process_uploaded_files
46
+ from .answer_grid import (
47
+ from_dict as grid_from_dict,
48
+ seed_from_parsed,
49
+ to_dict as grid_to_dict,
50
+ score_student as grid_score_student,
51
+ )
52
  from .parsers import AUTO, list_parsers
53
  from .report_generator import generate_student_report, build_student_markdown
54
 
 
58
  @asynccontextmanager
59
  async def lifespan(app: FastAPI):
60
  """Initialize database on startup."""
61
+ assert_production_db_config()
62
  await init_database()
63
  yield
64
 
 
78
  allow_headers=["*"],
79
  )
80
 
81
+ app.include_router(admin_router)
82
+
83
 
84
  # =============================================================================
85
  # Auth Endpoints
 
90
  password: str
91
 
92
 
93
+ class RegisterRequest(BaseModel):
94
+ email: str
95
+ password: str
96
+ full_name: str = ""
97
+ school: str = ""
98
+
99
+
100
  @app.post("/api/auth/register")
101
+ async def api_register(body: RegisterRequest):
102
+ user = await register_user(body.email, body.password, body.full_name, body.school)
103
  from .auth import create_access_token
104
  token = create_access_token({"sub": str(user["id"])})
105
+ return {"token": token, "user": public_user(user)}
106
 
107
 
108
  @app.post("/api/auth/login")
 
113
 
114
  @app.get("/api/auth/me")
115
  async def api_me(user=Depends(get_current_user)):
116
+ return public_user(user)
117
 
118
 
119
  # =============================================================================
 
145
  return session
146
 
147
 
148
+ class UpdateSessionRequest(BaseModel):
149
+ title: Optional[str] = None
150
+ subject: Optional[str] = None
151
+
152
+
153
+ @app.patch("/api/sessions/{session_id}")
154
+ async def api_update_session(
155
+ session_id: int, body: UpdateSessionRequest, user=Depends(get_current_user)
156
+ ):
157
+ await _session_or_404(session_id, user)
158
+ await update_session_meta(session_id, title=body.title, subject=body.subject)
159
+ return await get_session(session_id)
160
+
161
+
162
+ @app.delete("/api/sessions/{session_id}")
163
+ async def api_delete_session(session_id: int, user=Depends(get_current_user)):
164
+ await _session_or_404(session_id, user)
165
+ await delete_session(session_id)
166
+ return {"status": "deleted"}
167
+
168
+
169
  # =============================================================================
170
  # File Upload & Processing Endpoints
171
  # =============================================================================
 
197
  detail="data_type must be 'questions', 'answers', 'student_answers', or 'teacher_answers'",
198
  )
199
 
200
+ # Persist the raw source files (best-effort) before parsing consumes them.
201
+ for f in files:
202
+ try:
203
+ content = await f.read()
204
+ storage_path = await upload_raw_file(
205
+ session_id, data_type, f.filename or "file", content, f.content_type or ""
206
+ )
207
+ await save_raw_file(
208
+ session_id,
209
+ data_type,
210
+ f.filename or "file",
211
+ f.content_type or "",
212
+ len(content),
213
+ storage_path or "",
214
+ )
215
+ await f.seek(0)
216
+ except Exception:
217
+ # Raw-file archival is non-critical; never block parsing on it.
218
+ await f.seek(0)
219
+
220
  try:
221
  structured = await process_uploaded_files(
222
  files,
 
433
 
434
  try:
435
  html = await generate_student_report(grid, body.student_index, body.model)
 
436
  student_name = grid.students[body.student_index].name
437
+ # Link the report to a first-class student + persist this quiz result,
438
+ # so the data is queryable across quizzes for the dashboard.
439
+ student_id = await match_or_create_student(user["id"], student_name)
440
+ total, correct, score = grid_score_student(grid, body.student_index)
441
+ await save_student_result(
442
+ quiz_id=session_id,
443
+ student_id=student_id,
444
+ answers={"answers": list(grid.students[body.student_index].answers)},
445
+ total_questions=total,
446
+ correct_count=correct,
447
+ score=score,
448
+ )
449
+ report_id = await save_report(
450
+ session_id, None, html, student_id=student_id, model=body.model
451
+ )
452
+ return {
453
+ "report_id": report_id,
454
+ "html_content": html,
455
+ "student_name": student_name,
456
+ "student_id": student_id,
457
+ "score": score,
458
+ }
459
  except Exception as e:
460
  raise HTTPException(status_code=500, detail=f"Report generation failed: {str(e)}")
461
 
462
 
463
+ @app.post("/api/sessions/{session_id}/save-to-db")
464
+ async def api_save_to_db(session_id: int, user=Depends(get_current_user)):
465
+ """Commit the confirmed grid as durable analytics data.
466
+
467
+ Creates/matches a student per row (by name, within this teacher) and stores
468
+ each student's result for this quiz, then marks the quiz as saved. This is
469
+ what the "Save to database" button calls.
470
+ """
471
+ await _session_or_404(session_id, user)
472
+ grid = await _require_confirmed_grid(session_id)
473
+
474
+ saved = []
475
+ for idx, student in enumerate(grid.students):
476
+ student_id = await match_or_create_student(user["id"], student.name)
477
+ total, correct, score = grid_score_student(grid, idx)
478
+ await save_student_result(
479
+ quiz_id=session_id,
480
+ student_id=student_id,
481
+ answers={"answers": list(student.answers)},
482
+ total_questions=total,
483
+ correct_count=correct,
484
+ score=score,
485
+ )
486
+ saved.append({"student_id": student_id, "name": student.name, "score": score})
487
+
488
+ await update_session_status(session_id, "saved")
489
+ return {"status": "saved", "students_saved": len(saved), "students": saved}
490
+
491
+
492
  @app.get("/api/sessions/{session_id}/reports")
493
  async def api_list_reports(session_id: int, user=Depends(get_current_user)):
494
  session = await get_session(session_id)
chatkit/backend/app/models.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SQLAlchemy ORM models for ClassLens.
2
+
3
+ All tables are isolated in a dedicated schema (``classlens`` on Supabase) so they
4
+ never collide with other apps sharing the same Postgres instance.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import datetime
10
+ from typing import Optional
11
+
12
+ from sqlalchemy import (
13
+ JSON,
14
+ Boolean,
15
+ DateTime,
16
+ Float,
17
+ ForeignKey,
18
+ Integer,
19
+ MetaData,
20
+ String,
21
+ Text,
22
+ UniqueConstraint,
23
+ func,
24
+ )
25
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
26
+
27
+ from .db import active_schema
28
+
29
+ SCHEMA = active_schema()
30
+
31
+
32
+ def _fk(target: str) -> str:
33
+ """Schema-qualified FK target, e.g. 'classlens.teachers.id' or 'teachers.id'."""
34
+ return f"{SCHEMA}.{target}" if SCHEMA else target
35
+
36
+
37
+ class Base(DeclarativeBase):
38
+ metadata = MetaData(schema=SCHEMA)
39
+
40
+
41
+ class Teacher(Base):
42
+ __tablename__ = "teachers"
43
+
44
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
45
+ email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
46
+ password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
47
+ display_name: Mapped[str] = mapped_column(String(255), default="")
48
+ full_name: Mapped[str] = mapped_column(String(255), default="")
49
+ school: Mapped[str] = mapped_column(String(255), default="")
50
+ is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
51
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
52
+ last_login: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
53
+
54
+
55
+ class Student(Base):
56
+ __tablename__ = "students"
57
+ __table_args__ = (
58
+ UniqueConstraint("teacher_id", "normalized_name", name="uq_student_teacher_name"),
59
+ )
60
+
61
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
62
+ teacher_id: Mapped[int] = mapped_column(ForeignKey(_fk("teachers.id"), ondelete="CASCADE"), index=True)
63
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
64
+ normalized_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
65
+ external_id: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
66
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
67
+
68
+
69
+ class Quiz(Base):
70
+ __tablename__ = "quizzes"
71
+
72
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
73
+ teacher_id: Mapped[int] = mapped_column(ForeignKey(_fk("teachers.id"), ondelete="CASCADE"), index=True)
74
+ title: Mapped[str] = mapped_column(String(512), default="Untitled Session")
75
+ subject: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
76
+ status: Mapped[str] = mapped_column(String(32), default="draft")
77
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
78
+ updated_at: Mapped[datetime] = mapped_column(
79
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
80
+ )
81
+
82
+
83
+ class RawFile(Base):
84
+ __tablename__ = "raw_files"
85
+
86
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
87
+ quiz_id: Mapped[int] = mapped_column(ForeignKey(_fk("quizzes.id"), ondelete="CASCADE"), index=True)
88
+ data_type: Mapped[str] = mapped_column(String(64), nullable=False)
89
+ file_name: Mapped[str] = mapped_column(String(512), default="")
90
+ content_type: Mapped[str] = mapped_column(String(128), default="")
91
+ size_bytes: Mapped[int] = mapped_column(Integer, default=0)
92
+ storage_path: Mapped[str] = mapped_column(String(1024), default="")
93
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
94
+
95
+
96
+ class ParsedData(Base):
97
+ __tablename__ = "parsed_data"
98
+
99
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
100
+ quiz_id: Mapped[int] = mapped_column(ForeignKey(_fk("quizzes.id"), ondelete="CASCADE"), index=True)
101
+ data_type: Mapped[str] = mapped_column(String(64), nullable=False)
102
+ file_name: Mapped[str] = mapped_column(String(512), default="")
103
+ raw_text: Mapped[str] = mapped_column(Text, default="")
104
+ structured_data: Mapped[dict] = mapped_column(JSON, nullable=False)
105
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
106
+
107
+
108
+ class StudentResult(Base):
109
+ """One student's outcome on one quiz — the backbone for cross-quiz analytics."""
110
+
111
+ __tablename__ = "student_results"
112
+ __table_args__ = (
113
+ UniqueConstraint("quiz_id", "student_id", name="uq_result_quiz_student"),
114
+ )
115
+
116
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
117
+ quiz_id: Mapped[int] = mapped_column(ForeignKey(_fk("quizzes.id"), ondelete="CASCADE"), index=True)
118
+ student_id: Mapped[int] = mapped_column(ForeignKey(_fk("students.id"), ondelete="CASCADE"), index=True)
119
+ answers: Mapped[dict] = mapped_column(JSON, default=dict)
120
+ total_questions: Mapped[int] = mapped_column(Integer, default=0)
121
+ correct_count: Mapped[int] = mapped_column(Integer, default=0)
122
+ score: Mapped[float] = mapped_column(Float, default=0.0)
123
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
124
+
125
+
126
+ class Prompt(Base):
127
+ __tablename__ = "prompts"
128
+
129
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
130
+ teacher_id: Mapped[int] = mapped_column(ForeignKey(_fk("teachers.id"), ondelete="CASCADE"), index=True)
131
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
132
+ content: Mapped[str] = mapped_column(Text, nullable=False)
133
+ is_default: Mapped[bool] = mapped_column(Boolean, default=False)
134
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
135
+ updated_at: Mapped[datetime] = mapped_column(
136
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
137
+ )
138
+
139
+
140
+ class Report(Base):
141
+ __tablename__ = "reports"
142
+
143
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
144
+ quiz_id: Mapped[int] = mapped_column(ForeignKey(_fk("quizzes.id"), ondelete="CASCADE"), index=True)
145
+ student_id: Mapped[Optional[int]] = mapped_column(
146
+ ForeignKey(_fk("students.id"), ondelete="SET NULL"), nullable=True, index=True
147
+ )
148
+ prompt_id: Mapped[Optional[int]] = mapped_column(
149
+ ForeignKey(_fk("prompts.id"), ondelete="SET NULL"), nullable=True
150
+ )
151
+ html_content: Mapped[str] = mapped_column(Text, nullable=False)
152
+ model: Mapped[str] = mapped_column(String(64), default="")
153
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
chatkit/backend/app/storage.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Raw-file storage on Supabase Storage (object storage).
2
+
3
+ Bytes live in a Storage bucket; only metadata + the object path are kept in the
4
+ ``raw_files`` table. Uploads are best-effort: if storage isn't configured (e.g.
5
+ local dev or SQLite mode), uploads are skipped and an empty path is returned so
6
+ the rest of the flow keeps working.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from functools import lru_cache
12
+ from typing import Optional
13
+
14
+ from starlette.concurrency import run_in_threadpool
15
+
16
+ from .config import get_settings
17
+
18
+
19
+ def storage_enabled() -> bool:
20
+ s = get_settings()
21
+ return bool(s.supabase_url and s.supabase_service_role_key)
22
+
23
+
24
+ @lru_cache
25
+ def _client():
26
+ from supabase import create_client
27
+
28
+ s = get_settings()
29
+ return create_client(s.supabase_url, s.supabase_service_role_key)
30
+
31
+
32
+ def _ensure_bucket(bucket: str):
33
+ client = _client()
34
+ try:
35
+ existing = {b.name for b in client.storage.list_buckets()}
36
+ if bucket not in existing:
37
+ client.storage.create_bucket(bucket, options={"public": False})
38
+ except Exception:
39
+ # Bucket may already exist or listing may be restricted; uploads will surface real errors.
40
+ pass
41
+
42
+
43
+ def _upload_sync(path: str, data: bytes, content_type: str) -> str:
44
+ s = get_settings()
45
+ bucket = s.supabase_storage_bucket
46
+ _ensure_bucket(bucket)
47
+ client = _client()
48
+ client.storage.from_(bucket).upload(
49
+ path,
50
+ data,
51
+ {"content-type": content_type or "application/octet-stream", "upsert": "true"},
52
+ )
53
+ return path
54
+
55
+
56
+ async def upload_raw_file(
57
+ quiz_id: int, data_type: str, file_name: str, data: bytes, content_type: str
58
+ ) -> Optional[str]:
59
+ """Upload bytes and return the storage object path, or None if disabled."""
60
+ if not storage_enabled():
61
+ return None
62
+ safe_name = file_name.replace("/", "_") or "file"
63
+ path = f"quiz_{quiz_id}/{data_type}/{safe_name}"
64
+ return await run_in_threadpool(_upload_sync, path, data, content_type)
chatkit/backend/pyproject.toml CHANGED
@@ -12,6 +12,10 @@ dependencies = [
12
  "python-multipart>=0.0.6",
13
  "python-dotenv>=1.0.0",
14
  "aiosqlite>=0.19.0",
 
 
 
 
15
  "cryptography>=41.0.0",
16
  "pydantic>=2.0.0",
17
  "httpx>=0.25.0",
 
12
  "python-multipart>=0.0.6",
13
  "python-dotenv>=1.0.0",
14
  "aiosqlite>=0.19.0",
15
+ "sqlalchemy[asyncio]>=2.0.30",
16
+ "asyncpg>=0.29.0",
17
+ "alembic>=1.13.0",
18
+ "supabase>=2.6.0",
19
  "cryptography>=41.0.0",
20
  "pydantic>=2.0.0",
21
  "httpx>=0.25.0",
chatkit/backend/tests/test_csv_strict.py CHANGED
@@ -290,17 +290,17 @@ async def test_combined_answers_persists_both_subtypes(tmp_path, monkeypatch):
290
  from app import database
291
  from app.file_processor import process_uploaded_files
292
 
293
- # Point the DB at a fresh file
294
- db_file = tmp_path / "test.db"
295
- monkeypatch.setattr(database, "DATABASE_PATH", db_file)
296
- monkeypatch.setattr(
297
- database.get_settings.__wrapped__, "__defaults__", None, raising=False
298
- )
299
- # Ensure config returns no override
300
  from app import config as config_module
 
301
 
 
302
  cached = config_module.get_settings()
303
- monkeypatch.setattr(cached, "database_path", "", raising=False)
 
 
 
 
304
 
305
  await database.init_database()
306
  user_id = await database.create_user("t@example.com", "h", "T")
 
290
  from app import database
291
  from app.file_processor import process_uploaded_files
292
 
293
+ # Point the DB at a fresh local SQLite file and rebuild the engine.
 
 
 
 
 
 
294
  from app import config as config_module
295
+ from app import db as db_module
296
 
297
+ db_file = tmp_path / "test.db"
298
  cached = config_module.get_settings()
299
+ monkeypatch.setattr(cached, "database_provider", "sqlite", raising=False)
300
+ monkeypatch.setattr(cached, "supabase_db_url", "", raising=False)
301
+ monkeypatch.setattr(cached, "database_path", str(db_file), raising=False)
302
+ db_module.get_engine.cache_clear()
303
+ db_module.get_sessionmaker.cache_clear()
304
 
305
  await database.init_database()
306
  user_id = await database.create_user("t@example.com", "h", "T")
chatkit/backend/tests/test_database.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the SQLAlchemy data-access layer (run against a temp SQLite file)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from app import config as config_module
8
+ from app import database
9
+ from app import db as db_module
10
+ from app.answer_grid import from_dict as grid_from_dict, score_student
11
+
12
+
13
+ @pytest.fixture
14
+ async def fresh_db(tmp_path, monkeypatch):
15
+ db_file = tmp_path / "t.db"
16
+ cached = config_module.get_settings()
17
+ monkeypatch.setattr(cached, "database_provider", "sqlite", raising=False)
18
+ monkeypatch.setattr(cached, "supabase_db_url", "", raising=False)
19
+ monkeypatch.setattr(cached, "database_path", str(db_file), raising=False)
20
+ monkeypatch.setattr(cached, "admin_emails", "", raising=False)
21
+ db_module.get_engine.cache_clear()
22
+ db_module.get_sessionmaker.cache_clear()
23
+ await database.init_database()
24
+ yield
25
+ db_module.get_engine.cache_clear()
26
+ db_module.get_sessionmaker.cache_clear()
27
+
28
+
29
+ @pytest.mark.unit
30
+ async def test_teacher_profile_roundtrip(fresh_db):
31
+ uid = await database.create_user(
32
+ "t@example.com", "hash", full_name="Wang", school="First High"
33
+ )
34
+ user = await database.get_user_by_email("t@example.com")
35
+ assert user["id"] == uid
36
+ assert user["full_name"] == "Wang"
37
+ assert user["school"] == "First High"
38
+ assert user["is_admin"] is False
39
+
40
+
41
+ @pytest.mark.unit
42
+ async def test_student_matched_by_normalized_name(fresh_db):
43
+ uid = await database.create_user("t@example.com", "h")
44
+ a = await database.match_or_create_student(uid, "Alice Chen")
45
+ b = await database.match_or_create_student(uid, " alice chen ")
46
+ c = await database.match_or_create_student(uid, "Bob")
47
+ assert a == b # whitespace/case-insensitive match
48
+ assert a != c
49
+ students = await database.get_students(uid)
50
+ assert len(students) == 2
51
+
52
+
53
+ @pytest.mark.unit
54
+ async def test_student_timeline_across_quizzes(fresh_db):
55
+ uid = await database.create_user("t@example.com", "h")
56
+ sid = await database.match_or_create_student(uid, "Alice")
57
+
58
+ q1 = await database.create_session(uid, "Quiz 1")
59
+ await database.save_student_result(q1, sid, {"answers": ["A"]}, 5, 3, 60.0)
60
+ q2 = await database.create_session(uid, "Quiz 2")
61
+ await database.save_student_result(q2, sid, {"answers": ["B"]}, 5, 5, 100.0)
62
+
63
+ timeline = await database.get_student_timeline(sid)
64
+ assert [t["score"] for t in timeline] == [60.0, 100.0]
65
+ assert timeline[0]["quiz_title"] == "Quiz 1"
66
+
67
+
68
+ @pytest.mark.unit
69
+ async def test_save_student_result_is_upsert(fresh_db):
70
+ uid = await database.create_user("t@example.com", "h")
71
+ sid = await database.match_or_create_student(uid, "Alice")
72
+ q = await database.create_session(uid, "Quiz")
73
+ await database.save_student_result(q, sid, {"answers": []}, 5, 2, 40.0)
74
+ await database.save_student_result(q, sid, {"answers": []}, 5, 4, 80.0)
75
+ timeline = await database.get_student_timeline(sid)
76
+ assert len(timeline) == 1 # same quiz+student -> updated, not duplicated
77
+ assert timeline[0]["score"] == 80.0
78
+
79
+
80
+ @pytest.mark.unit
81
+ async def test_stats_counts(fresh_db):
82
+ uid = await database.create_user("t@example.com", "h")
83
+ sid = await database.match_or_create_student(uid, "Alice")
84
+ q = await database.create_session(uid, "Quiz")
85
+ await database.save_student_result(q, sid, {"answers": []}, 3, 3, 100.0)
86
+ stats = await database.get_stats()
87
+ assert stats["teachers"] == 1
88
+ assert stats["students"] == 1
89
+ assert stats["quizzes"] == 1
90
+ assert stats["student_results"] == 1
91
+
92
+
93
+ @pytest.mark.unit
94
+ def test_score_student_grades_only_concrete_keys():
95
+ grid = grid_from_dict(
96
+ {
97
+ "total_questions": 4,
98
+ # Q4 official is blank -> not graded
99
+ "official_answers": ["A", "B", "C", ""],
100
+ "students": [{"name": "Alice", "answers": ["A", "X", "=", ""]}],
101
+ "questions": [],
102
+ }
103
+ )
104
+ graded, correct, score = score_student(grid, 0)
105
+ # Graded = Q1..Q3 (3). Correct: Q1 (A), Q3 ("=") -> 2 correct, Q2 wrong.
106
+ assert graded == 3
107
+ assert correct == 2
108
+ assert score == pytest.approx(66.7, abs=0.1)
chatkit/env.example CHANGED
@@ -1,50 +1,44 @@
1
- # ExamInsight Environment Variables
2
- # Copy to .env for local development
3
- # For HF Spaces, add these as Secrets in the Space settings
4
 
5
  # =============================================================================
6
- # REQUIRED
7
  # =============================================================================
8
-
9
- # OpenAI API Key (required for ChatKit)
10
  OPENAI_API_KEY=sk-your-openai-api-key
11
 
12
  # =============================================================================
13
- # GOOGLE OAUTH (optional - for private Google Sheets)
14
  # =============================================================================
 
 
15
 
16
- # Get these from Google Cloud Console > APIs & Services > Credentials
17
- GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
18
- GOOGLE_CLIENT_SECRET=GOCSPX-your-client-secret
19
 
20
- # Redirect URI - update for production
21
- # Local: http://localhost:8000/auth/callback
22
- # HF Spaces: https://taboola-cz-examinsight.hf.space/auth/callback
23
- GOOGLE_REDIRECT_URI=http://localhost:8000/auth/callback
24
 
25
- # =============================================================================
26
- # EMAIL (optional - for sending reports)
27
- # =============================================================================
 
28
 
29
- # Option 1: Gmail SMTP (easier setup)
30
- GMAIL_USER=your-email@gmail.com
31
- GMAIL_APP_PASSWORD=your-16-char-app-password
32
-
33
- # Option 2: SendGrid API
34
- SENDGRID_API_KEY=SG.your-sendgrid-api-key
35
- SENDGRID_FROM_EMAIL=examinsight@yourdomain.com
36
 
37
  # =============================================================================
38
- # SECURITY
39
  # =============================================================================
 
 
40
 
41
- # Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
42
- ENCRYPTION_KEY=your-fernet-encryption-key
43
 
44
  # =============================================================================
45
- # FRONTEND (for HF Spaces domain key)
46
  # =============================================================================
47
-
48
- # Register your HF Space domain at:
49
- # https://platform.openai.com/settings/organization/security/domain-allowlist
50
- VITE_CHATKIT_API_DOMAIN_KEY=domain_pk_your-production-key
 
1
+ # ClassLens Environment Variables
2
+ # Copy to .env (project root or chatkit/) for local development.
3
+ # For HF Spaces, add these as Secrets in the Space settings. NEVER commit real secrets.
4
 
5
  # =============================================================================
6
+ # OpenAI (vision parsing + report generation)
7
  # =============================================================================
 
 
8
  OPENAI_API_KEY=sk-your-openai-api-key
9
 
10
  # =============================================================================
11
+ # Database
12
  # =============================================================================
13
+ # Provider: "supabase" (Postgres, durable) or "sqlite" (local dev/tests)
14
+ DATABASE_PROVIDER=supabase
15
 
16
+ # Postgres schema that isolates ClassLens tables from anything else in the DB.
17
+ DB_SCHEMA=classlens
 
18
 
19
+ # Supabase connection (Project Settings > Database > Connection string).
20
+ # Use the session pooler (port 5432). Keep the password URL-safe.
21
+ SUPABASE_DB_URL=postgresql://postgres.<ref>:<password>@<region>.pooler.supabase.com:5432/postgres
 
22
 
23
+ # Supabase project URL + service-role key (only used for raw-file Storage).
24
+ SUPABASE_URL=https://<ref>.supabase.co
25
+ SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
26
+ SUPABASE_STORAGE_BUCKET=classlens-raw-files
27
 
28
+ # Local SQLite path (only used when DATABASE_PROVIDER=sqlite; blank = auto).
29
+ DATABASE_PATH=
 
 
 
 
 
30
 
31
  # =============================================================================
32
+ # Auth
33
  # =============================================================================
34
+ JWT_SECRET_KEY=change-me-in-prod
35
+ JWT_EXPIRE_MINUTES=1440
36
 
37
+ # Comma-separated emails that are promoted to admin on startup.
38
+ ADMIN_EMAILS=you@example.com
39
 
40
  # =============================================================================
41
+ # Misc
42
  # =============================================================================
43
+ ENCRYPTION_KEY=
44
+ FRONTEND_URL=http://localhost:3000
 
 
chatkit/frontend/src/App.tsx CHANGED
@@ -8,16 +8,12 @@ import { ParsedDataSummary } from "./components/step2/ParsedDataSummary";
8
  import { StudentSelector } from "./components/step2/StudentSelector";
9
  import { PromptEditor } from "./components/step2/PromptEditor";
10
  import { ReportViewer } from "./components/step3/ReportViewer";
 
11
  import { apiGet, apiPost, getToken, clearToken } from "./lib/api";
 
12
 
13
  const INVITE_CODE = "taboola-npo-cz";
14
 
15
- interface User {
16
- id: number;
17
- email: string;
18
- display_name: string;
19
- }
20
-
21
  interface StudentInfo {
22
  index: number;
23
  name: string;
@@ -43,6 +39,7 @@ export default function App() {
43
  const [selectedIndices, setSelectedIndices] = useState<number[]>([]);
44
  const [reports, setReports] = useState<StudentReport[]>([]);
45
  const [isGenerating, setIsGenerating] = useState(false);
 
46
 
47
  // Check for saved JWT on mount
48
  useEffect(() => {
@@ -205,9 +202,32 @@ ${doneReports
205
  return <LoginForm onLogin={setUser} />;
206
  }
207
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  return (
209
  <div className="min-h-screen">
210
- <Header userEmail={user.email} onLogout={handleLogout} />
 
 
 
 
 
 
211
 
212
  <div className="pt-20">
213
  <StepIndicator currentStep={currentStep} onStepClick={handleStepClick} />
@@ -285,6 +305,14 @@ ${doneReports
285
  isGenerating={isGenerating}
286
  onBack={() => setCurrentStep(2)}
287
  onExportAllHtml={handleExportAllHtml}
 
 
 
 
 
 
 
 
288
  />
289
  </div>
290
  )}
 
8
  import { StudentSelector } from "./components/step2/StudentSelector";
9
  import { PromptEditor } from "./components/step2/PromptEditor";
10
  import { ReportViewer } from "./components/step3/ReportViewer";
11
+ import { AdminDashboard } from "./components/admin/AdminDashboard";
12
  import { apiGet, apiPost, getToken, clearToken } from "./lib/api";
13
+ import type { User } from "./types";
14
 
15
  const INVITE_CODE = "taboola-npo-cz";
16
 
 
 
 
 
 
 
17
  interface StudentInfo {
18
  index: number;
19
  name: string;
 
39
  const [selectedIndices, setSelectedIndices] = useState<number[]>([]);
40
  const [reports, setReports] = useState<StudentReport[]>([]);
41
  const [isGenerating, setIsGenerating] = useState(false);
42
+ const [showAdmin, setShowAdmin] = useState(false);
43
 
44
  // Check for saved JWT on mount
45
  useEffect(() => {
 
202
  return <LoginForm onLogin={setUser} />;
203
  }
204
 
205
+ if (showAdmin && user.is_admin) {
206
+ return (
207
+ <div className="min-h-screen">
208
+ <Header
209
+ userEmail={user.email}
210
+ isAdmin={user.is_admin}
211
+ showAdmin={showAdmin}
212
+ onToggleAdmin={() => setShowAdmin((v) => !v)}
213
+ onLogout={handleLogout}
214
+ />
215
+ <div className="pt-20">
216
+ <AdminDashboard />
217
+ </div>
218
+ </div>
219
+ );
220
+ }
221
+
222
  return (
223
  <div className="min-h-screen">
224
+ <Header
225
+ userEmail={user.email}
226
+ isAdmin={user.is_admin}
227
+ showAdmin={showAdmin}
228
+ onToggleAdmin={() => setShowAdmin((v) => !v)}
229
+ onLogout={handleLogout}
230
+ />
231
 
232
  <div className="pt-20">
233
  <StepIndicator currentStep={currentStep} onStepClick={handleStepClick} />
 
305
  isGenerating={isGenerating}
306
  onBack={() => setCurrentStep(2)}
307
  onExportAllHtml={handleExportAllHtml}
308
+ onSaveToDb={
309
+ sessionId
310
+ ? () =>
311
+ apiPost<{ students_saved: number }>(
312
+ `/api/sessions/${sessionId}/save-to-db`
313
+ )
314
+ : undefined
315
+ }
316
  />
317
  </div>
318
  )}
chatkit/frontend/src/components/Header.tsx CHANGED
@@ -3,9 +3,12 @@ import { clearToken } from "../lib/api";
3
  interface HeaderProps {
4
  userEmail: string;
5
  onLogout: () => void;
 
 
 
6
  }
7
 
8
- export function Header({ userEmail, onLogout }: HeaderProps) {
9
  const handleLogout = () => {
10
  clearToken();
11
  onLogout();
@@ -29,6 +32,18 @@ export function Header({ userEmail, onLogout }: HeaderProps) {
29
  </div>
30
 
31
  <div className="flex items-center gap-4">
 
 
 
 
 
 
 
 
 
 
 
 
32
  <div className="flex items-center gap-2 px-4 py-2 rounded-full bg-[var(--color-success)]/10 border border-[var(--color-success)]/20">
33
  <span className="w-2 h-2 rounded-full bg-[var(--color-success)]"></span>
34
  <span className="text-sm font-medium text-[var(--color-success)]">
 
3
  interface HeaderProps {
4
  userEmail: string;
5
  onLogout: () => void;
6
+ isAdmin?: boolean;
7
+ showAdmin?: boolean;
8
+ onToggleAdmin?: () => void;
9
  }
10
 
11
+ export function Header({ userEmail, onLogout, isAdmin, showAdmin, onToggleAdmin }: HeaderProps) {
12
  const handleLogout = () => {
13
  clearToken();
14
  onLogout();
 
32
  </div>
33
 
34
  <div className="flex items-center gap-4">
35
+ {isAdmin && onToggleAdmin && (
36
+ <button
37
+ onClick={onToggleAdmin}
38
+ className={`text-sm font-medium px-4 py-2 rounded-full border transition-colors ${
39
+ showAdmin
40
+ ? "bg-[var(--color-primary)] text-white border-transparent"
41
+ : "text-[var(--color-text-muted)] border-[var(--color-border)] hover:text-[var(--color-text)]"
42
+ }`}
43
+ >
44
+ {showAdmin ? "← 返回應用" : "管理後台"}
45
+ </button>
46
+ )}
47
  <div className="flex items-center gap-2 px-4 py-2 rounded-full bg-[var(--color-success)]/10 border border-[var(--color-success)]/20">
48
  <span className="w-2 h-2 rounded-full bg-[var(--color-success)]"></span>
49
  <span className="text-sm font-medium text-[var(--color-success)]">
chatkit/frontend/src/components/LoginForm.tsx CHANGED
@@ -2,11 +2,7 @@ import { useState } from "react";
2
  import { apiPost, setToken } from "../lib/api";
3
  import { CLASSLENS_ICON } from "../lib/icon";
4
 
5
- interface User {
6
- id: number;
7
- email: string;
8
- display_name: string;
9
- }
10
 
11
  interface AuthResponse {
12
  token: string;
@@ -21,6 +17,8 @@ export function LoginForm({ onLogin }: LoginFormProps) {
21
  const [mode, setMode] = useState<"login" | "register">("login");
22
  const [email, setEmail] = useState("");
23
  const [password, setPassword] = useState("");
 
 
24
  const [error, setError] = useState("");
25
  const [loading, setLoading] = useState(false);
26
 
@@ -31,7 +29,11 @@ export function LoginForm({ onLogin }: LoginFormProps) {
31
 
32
  try {
33
  const endpoint = mode === "login" ? "/api/auth/login" : "/api/auth/register";
34
- const result = await apiPost<AuthResponse>(endpoint, { email, password });
 
 
 
 
35
  setToken(result.token);
36
  onLogin(result.user);
37
  } catch (err: unknown) {
@@ -103,6 +105,36 @@ export function LoginForm({ onLogin }: LoginFormProps) {
103
  autoFocus
104
  />
105
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  <div>
107
  <label className="block text-sm font-medium text-[var(--color-text-muted)] mb-2">
108
  密碼
 
2
  import { apiPost, setToken } from "../lib/api";
3
  import { CLASSLENS_ICON } from "../lib/icon";
4
 
5
+ import type { User } from "../types";
 
 
 
 
6
 
7
  interface AuthResponse {
8
  token: string;
 
17
  const [mode, setMode] = useState<"login" | "register">("login");
18
  const [email, setEmail] = useState("");
19
  const [password, setPassword] = useState("");
20
+ const [fullName, setFullName] = useState("");
21
+ const [school, setSchool] = useState("");
22
  const [error, setError] = useState("");
23
  const [loading, setLoading] = useState(false);
24
 
 
29
 
30
  try {
31
  const endpoint = mode === "login" ? "/api/auth/login" : "/api/auth/register";
32
+ const body =
33
+ mode === "login"
34
+ ? { email, password }
35
+ : { email, password, full_name: fullName, school };
36
+ const result = await apiPost<AuthResponse>(endpoint, body);
37
  setToken(result.token);
38
  onLogin(result.user);
39
  } catch (err: unknown) {
 
105
  autoFocus
106
  />
107
  </div>
108
+ {mode === "register" && (
109
+ <>
110
+ <div>
111
+ <label className="block text-sm font-medium text-[var(--color-text-muted)] mb-2">
112
+ 姓名
113
+ </label>
114
+ <input
115
+ type="text"
116
+ value={fullName}
117
+ onChange={(e) => setFullName(e.target.value)}
118
+ placeholder="王小明"
119
+ required
120
+ className="input"
121
+ />
122
+ </div>
123
+ <div>
124
+ <label className="block text-sm font-medium text-[var(--color-text-muted)] mb-2">
125
+ 學校
126
+ </label>
127
+ <input
128
+ type="text"
129
+ value={school}
130
+ onChange={(e) => setSchool(e.target.value)}
131
+ placeholder="台北市立第一高級中學"
132
+ required
133
+ className="input"
134
+ />
135
+ </div>
136
+ </>
137
+ )}
138
  <div>
139
  <label className="block text-sm font-medium text-[var(--color-text-muted)] mb-2">
140
  密碼
chatkit/frontend/src/components/admin/AdminDashboard.tsx ADDED
@@ -0,0 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from "react";
2
+ import { apiGet, apiPut, apiDelete } from "../../lib/api";
3
+
4
+ type Tab = "overview" | "teachers" | "sessions" | "students";
5
+
6
+ interface Stats {
7
+ teachers: number;
8
+ students: number;
9
+ quizzes: number;
10
+ reports: number;
11
+ student_results: number;
12
+ prompts: number;
13
+ schema_version: string | null;
14
+ }
15
+
16
+ interface TeacherRow {
17
+ id: number;
18
+ email: string;
19
+ full_name: string;
20
+ school: string;
21
+ is_admin: boolean;
22
+ created_at: string;
23
+ quiz_count: number;
24
+ student_count: number;
25
+ }
26
+
27
+ interface SessionRow {
28
+ id: number;
29
+ title: string;
30
+ subject: string | null;
31
+ status: string;
32
+ teacher_email: string;
33
+ report_count: number;
34
+ created_at: string;
35
+ }
36
+
37
+ interface StudentRow {
38
+ id: number;
39
+ name: string;
40
+ teacher_email: string;
41
+ result_count: number;
42
+ avg_score: number | null;
43
+ }
44
+
45
+ interface TimelineEntry {
46
+ quiz_title: string;
47
+ quiz_subject: string | null;
48
+ quiz_created_at: string;
49
+ total_questions: number;
50
+ correct_count: number;
51
+ score: number;
52
+ }
53
+
54
+ const TABS: { key: Tab; label: string }[] = [
55
+ { key: "overview", label: "總覽" },
56
+ { key: "teachers", label: "教師" },
57
+ { key: "sessions", label: "測驗" },
58
+ { key: "students", label: "學生" },
59
+ ];
60
+
61
+ export function AdminDashboard() {
62
+ const [tab, setTab] = useState<Tab>("overview");
63
+
64
+ return (
65
+ <main className="max-w-6xl mx-auto px-4 pb-12">
66
+ <div className="text-center mb-6">
67
+ <h2 className="font-display text-2xl font-bold text-[var(--color-text)]">管理後台</h2>
68
+ <p className="text-[var(--color-text-muted)] mt-1">資料庫管理與使用分析</p>
69
+ </div>
70
+
71
+ <div className="flex justify-center gap-2 mb-6">
72
+ {TABS.map((t) => (
73
+ <button
74
+ key={t.key}
75
+ onClick={() => setTab(t.key)}
76
+ className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
77
+ tab === t.key
78
+ ? "bg-[var(--color-primary)] text-white"
79
+ : "text-[var(--color-text-muted)] hover:text-[var(--color-text)] border border-[var(--color-border)]"
80
+ }`}
81
+ >
82
+ {t.label}
83
+ </button>
84
+ ))}
85
+ </div>
86
+
87
+ {tab === "overview" && <Overview />}
88
+ {tab === "teachers" && <Teachers />}
89
+ {tab === "sessions" && <Sessions />}
90
+ {tab === "students" && <Students />}
91
+ </main>
92
+ );
93
+ }
94
+
95
+ function useFetch<T>(path: string, deps: unknown[] = []) {
96
+ const [data, setData] = useState<T | null>(null);
97
+ const [error, setError] = useState("");
98
+ const [loading, setLoading] = useState(true);
99
+ const [nonce, setNonce] = useState(0);
100
+ const reload = () => setNonce((n) => n + 1);
101
+
102
+ useEffect(() => {
103
+ let active = true;
104
+ setLoading(true);
105
+ apiGet<T>(path)
106
+ .then((d) => active && setData(d))
107
+ .catch((e) => active && setError(e instanceof Error ? e.message : "Error"))
108
+ .finally(() => active && setLoading(false));
109
+ return () => {
110
+ active = false;
111
+ };
112
+ // eslint-disable-next-line react-hooks/exhaustive-deps
113
+ }, [path, nonce, ...deps]);
114
+
115
+ return { data, error, loading, reload };
116
+ }
117
+
118
+ function StatCard({ label, value }: { label: string; value: number | string }) {
119
+ return (
120
+ <div className="card p-6 text-center">
121
+ <div className="text-3xl font-bold text-[var(--color-primary)]">{value}</div>
122
+ <div className="text-sm text-[var(--color-text-muted)] mt-1">{label}</div>
123
+ </div>
124
+ );
125
+ }
126
+
127
+ function Overview() {
128
+ const { data, loading, error } = useFetch<Stats>("/api/admin/stats");
129
+ if (loading) return <Spinner />;
130
+ if (error || !data) return <ErrorBox msg={error} />;
131
+ return (
132
+ <div>
133
+ <div className="grid grid-cols-2 md:grid-cols-3 gap-4">
134
+ <StatCard label="教師" value={data.teachers} />
135
+ <StatCard label="學生" value={data.students} />
136
+ <StatCard label="測驗" value={data.quizzes} />
137
+ <StatCard label="已生成報告" value={data.reports} />
138
+ <StatCard label="學生成績紀錄" value={data.student_results} />
139
+ <StatCard label="提示詞" value={data.prompts} />
140
+ </div>
141
+ <p className="text-center text-xs text-[var(--color-text-muted)] mt-6">
142
+ 資料庫結構版本:{data.schema_version ?? "未知"}
143
+ </p>
144
+ </div>
145
+ );
146
+ }
147
+
148
+ function Teachers() {
149
+ const { data, loading, error, reload } = useFetch<{ users: TeacherRow[] }>("/api/admin/users");
150
+ if (loading) return <Spinner />;
151
+ if (error || !data) return <ErrorBox msg={error} />;
152
+
153
+ const toggleAdmin = async (u: TeacherRow) => {
154
+ await apiPut(`/api/admin/users/${u.id}/admin`, { is_admin: !u.is_admin });
155
+ reload();
156
+ };
157
+ const remove = async (u: TeacherRow) => {
158
+ if (!confirm(`刪除教師 ${u.email}?此操作會一併刪除其所有測驗與報告。`)) return;
159
+ await apiDelete(`/api/admin/users/${u.id}`);
160
+ reload();
161
+ };
162
+
163
+ return (
164
+ <Table headers={["Email", "姓名", "學校", "測驗", "學生", "管理員", ""]}>
165
+ {data.users.map((u) => (
166
+ <tr key={u.id} className="border-t border-[var(--color-border)]">
167
+ <Td>{u.email}</Td>
168
+ <Td>{u.full_name || "—"}</Td>
169
+ <Td>{u.school || "—"}</Td>
170
+ <Td>{u.quiz_count}</Td>
171
+ <Td>{u.student_count}</Td>
172
+ <Td>
173
+ <button onClick={() => toggleAdmin(u)} className="text-sm underline text-[var(--color-primary)]">
174
+ {u.is_admin ? "是" : "否"}
175
+ </button>
176
+ </Td>
177
+ <Td>
178
+ <button onClick={() => remove(u)} className="text-sm text-red-400 hover:underline">
179
+ 刪除
180
+ </button>
181
+ </Td>
182
+ </tr>
183
+ ))}
184
+ </Table>
185
+ );
186
+ }
187
+
188
+ function Sessions() {
189
+ const { data, loading, error, reload } = useFetch<{ sessions: SessionRow[] }>("/api/admin/sessions");
190
+ if (loading) return <Spinner />;
191
+ if (error || !data) return <ErrorBox msg={error} />;
192
+
193
+ const remove = async (s: SessionRow) => {
194
+ if (!confirm(`刪除測驗「${s.title}」?`)) return;
195
+ await apiDelete(`/api/admin/sessions/${s.id}`);
196
+ reload();
197
+ };
198
+
199
+ return (
200
+ <Table headers={["標題", "科目", "教師", "狀態", "報告", "建立時間", ""]}>
201
+ {data.sessions.map((s) => (
202
+ <tr key={s.id} className="border-t border-[var(--color-border)]">
203
+ <Td>{s.title}</Td>
204
+ <Td>{s.subject || "—"}</Td>
205
+ <Td>{s.teacher_email}</Td>
206
+ <Td>{s.status}</Td>
207
+ <Td>{s.report_count}</Td>
208
+ <Td>{new Date(s.created_at).toLocaleDateString()}</Td>
209
+ <Td>
210
+ <button onClick={() => remove(s)} className="text-sm text-red-400 hover:underline">
211
+ 刪除
212
+ </button>
213
+ </Td>
214
+ </tr>
215
+ ))}
216
+ </Table>
217
+ );
218
+ }
219
+
220
+ function Students() {
221
+ const { data, loading, error } = useFetch<{ students: StudentRow[] }>("/api/admin/students");
222
+ const [selected, setSelected] = useState<number | null>(null);
223
+
224
+ if (loading) return <Spinner />;
225
+ if (error || !data) return <ErrorBox msg={error} />;
226
+
227
+ if (selected !== null) {
228
+ return <StudentDetail studentId={selected} onBack={() => setSelected(null)} />;
229
+ }
230
+
231
+ return (
232
+ <Table headers={["姓名", "教師", "測驗次數", "平均分數", ""]}>
233
+ {data.students.map((s) => (
234
+ <tr key={s.id} className="border-t border-[var(--color-border)]">
235
+ <Td>{s.name}</Td>
236
+ <Td>{s.teacher_email}</Td>
237
+ <Td>{s.result_count}</Td>
238
+ <Td>{s.avg_score ?? "—"}</Td>
239
+ <Td>
240
+ <button onClick={() => setSelected(s.id)} className="text-sm underline text-[var(--color-primary)]">
241
+ 查看歷程
242
+ </button>
243
+ </Td>
244
+ </tr>
245
+ ))}
246
+ </Table>
247
+ );
248
+ }
249
+
250
+ function StudentDetail({ studentId, onBack }: { studentId: number; onBack: () => void }) {
251
+ const { data, loading, error } = useFetch<{
252
+ student: { name: string };
253
+ timeline: TimelineEntry[];
254
+ }>(`/api/admin/students/${studentId}`);
255
+
256
+ if (loading) return <Spinner />;
257
+ if (error || !data) return <ErrorBox msg={error} />;
258
+
259
+ return (
260
+ <div>
261
+ <button onClick={onBack} className="text-sm text-[var(--color-text-muted)] hover:underline mb-4">
262
+ ← 返回學生列表
263
+ </button>
264
+ <h3 className="font-display text-xl font-semibold mb-4 text-[var(--color-text)]">
265
+ {data.student.name} 的跨測驗歷程
266
+ </h3>
267
+ {data.timeline.length === 0 ? (
268
+ <p className="text-[var(--color-text-muted)]">尚無成績紀錄</p>
269
+ ) : (
270
+ <Table headers={["測驗", "科目", "日期", "答對", "總題數", "分數"]}>
271
+ {data.timeline.map((t, i) => (
272
+ <tr key={i} className="border-t border-[var(--color-border)]">
273
+ <Td>{t.quiz_title}</Td>
274
+ <Td>{t.quiz_subject || "—"}</Td>
275
+ <Td>{new Date(t.quiz_created_at).toLocaleDateString()}</Td>
276
+ <Td>{t.correct_count}</Td>
277
+ <Td>{t.total_questions}</Td>
278
+ <Td>
279
+ <span className="font-semibold text-[var(--color-primary)]">{t.score}</span>
280
+ </Td>
281
+ </tr>
282
+ ))}
283
+ </Table>
284
+ )}
285
+ </div>
286
+ );
287
+ }
288
+
289
+ function Table({ headers, children }: { headers: string[]; children: React.ReactNode }) {
290
+ return (
291
+ <div className="card overflow-x-auto">
292
+ <table className="w-full text-sm">
293
+ <thead>
294
+ <tr className="text-left text-[var(--color-text-muted)]">
295
+ {headers.map((h, i) => (
296
+ <th key={i} className="px-4 py-3 font-medium">
297
+ {h}
298
+ </th>
299
+ ))}
300
+ </tr>
301
+ </thead>
302
+ <tbody className="text-[var(--color-text)]">{children}</tbody>
303
+ </table>
304
+ </div>
305
+ );
306
+ }
307
+
308
+ function Td({ children }: { children: React.ReactNode }) {
309
+ return <td className="px-4 py-3">{children}</td>;
310
+ }
311
+
312
+ function Spinner() {
313
+ return (
314
+ <div className="flex justify-center py-12">
315
+ <div className="w-6 h-6 border-2 border-[var(--color-primary)] border-t-transparent rounded-full animate-spin" />
316
+ </div>
317
+ );
318
+ }
319
+
320
+ function ErrorBox({ msg }: { msg: string }) {
321
+ return <div className="card p-6 text-center text-red-400">{msg || "載入失敗"}</div>;
322
+ }
chatkit/frontend/src/components/step3/ReportViewer.tsx CHANGED
@@ -6,6 +6,7 @@ interface ReportViewerProps {
6
  isGenerating: boolean;
7
  onBack: () => void;
8
  onExportAllHtml: () => void;
 
9
  }
10
 
11
  export function ReportViewer({
@@ -13,10 +14,27 @@ export function ReportViewer({
13
  isGenerating,
14
  onBack,
15
  onExportAllHtml,
 
16
  }: ReportViewerProps) {
17
  const [activeIndex, setActiveIndex] = useState(0);
 
 
18
  const iframeRef = useRef<HTMLIFrameElement>(null);
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  // Auto-switch to the report currently being generated
21
  useEffect(() => {
22
  const generatingIdx = reports.findIndex((r) => r.status === "generating");
@@ -83,11 +101,31 @@ export function ReportViewer({
83
  </button>
84
  )}
85
  {doneCount > 1 && (
86
- <button onClick={onExportAllHtml} className="btn btn-primary text-sm">
87
  下載全部報告
88
  </button>
89
  )}
 
 
 
 
 
 
 
 
 
 
 
90
  </div>
 
 
 
 
 
 
 
 
 
91
 
92
  {/* Student tabs */}
93
  <div className="flex gap-2 overflow-x-auto pb-2">
 
6
  isGenerating: boolean;
7
  onBack: () => void;
8
  onExportAllHtml: () => void;
9
+ onSaveToDb?: () => Promise<{ students_saved: number }>;
10
  }
11
 
12
  export function ReportViewer({
 
14
  isGenerating,
15
  onBack,
16
  onExportAllHtml,
17
+ onSaveToDb,
18
  }: ReportViewerProps) {
19
  const [activeIndex, setActiveIndex] = useState(0);
20
+ const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle");
21
+ const [saveMsg, setSaveMsg] = useState("");
22
  const iframeRef = useRef<HTMLIFrameElement>(null);
23
 
24
+ const handleSaveToDb = async () => {
25
+ if (!onSaveToDb) return;
26
+ setSaveState("saving");
27
+ setSaveMsg("");
28
+ try {
29
+ const res = await onSaveToDb();
30
+ setSaveState("saved");
31
+ setSaveMsg(`已儲存 ${res.students_saved} 位學生成績`);
32
+ } catch (e) {
33
+ setSaveState("error");
34
+ setSaveMsg(e instanceof Error ? e.message : "儲存失敗");
35
+ }
36
+ };
37
+
38
  // Auto-switch to the report currently being generated
39
  useEffect(() => {
40
  const generatingIdx = reports.findIndex((r) => r.status === "generating");
 
101
  </button>
102
  )}
103
  {doneCount > 1 && (
104
+ <button onClick={onExportAllHtml} className="btn btn-outline text-sm">
105
  下載全部報告
106
  </button>
107
  )}
108
+ {onSaveToDb && !isGenerating && doneCount > 0 && (
109
+ <button
110
+ onClick={handleSaveToDb}
111
+ disabled={saveState === "saving" || saveState === "saved"}
112
+ className="btn btn-primary text-sm disabled:opacity-60"
113
+ >
114
+ {saveState === "saving" && "儲存中..."}
115
+ {saveState === "saved" && "✅ 已儲存"}
116
+ {(saveState === "idle" || saveState === "error") && "儲存至資料庫"}
117
+ </button>
118
+ )}
119
  </div>
120
+ {saveMsg && (
121
+ <p
122
+ className={`text-sm text-right ${
123
+ saveState === "error" ? "text-red-400" : "text-[var(--color-success)]"
124
+ }`}
125
+ >
126
+ {saveMsg}
127
+ </p>
128
+ )}
129
 
130
  {/* Student tabs */}
131
  <div className="flex gap-2 overflow-x-auto pb-2">
chatkit/frontend/src/types/index.ts ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ export interface User {
2
+ id: number;
3
+ email: string;
4
+ display_name: string;
5
+ full_name?: string;
6
+ school?: string;
7
+ is_admin?: boolean;
8
+ }