Spaces:
Sleeping
Sleeping
Add database management: Supabase persistence, teacher profiles, admin dashboard
#1
by kuanz - opened
- Dockerfile +8 -3
- chatkit/backend/DATABASE.md +56 -0
- chatkit/backend/alembic.ini +40 -0
- chatkit/backend/alembic/env.py +69 -0
- chatkit/backend/alembic/script.py.mako +22 -0
- chatkit/backend/alembic/versions/0001_initial.py +32 -0
- chatkit/backend/app/admin.py +84 -0
- chatkit/backend/app/answer_grid.py +15 -0
- chatkit/backend/app/auth.py +27 -4
- chatkit/backend/app/config.py +19 -1
- chatkit/backend/app/database.py +488 -242
- chatkit/backend/app/db.py +110 -0
- chatkit/backend/app/main.py +122 -9
- chatkit/backend/app/models.py +153 -0
- chatkit/backend/app/storage.py +64 -0
- chatkit/backend/pyproject.toml +4 -0
- chatkit/backend/tests/test_csv_strict.py +8 -8
- chatkit/backend/tests/test_database.py +108 -0
- chatkit/env.example +26 -32
- chatkit/frontend/src/App.tsx +35 -7
- chatkit/frontend/src/components/Header.tsx +16 -1
- chatkit/frontend/src/components/LoginForm.tsx +38 -6
- chatkit/frontend/src/components/admin/AdminDashboard.tsx +322 -0
- chatkit/frontend/src/components/step3/ReportViewer.tsx +39 -1
- chatkit/frontend/src/types/index.ts +8 -0
Dockerfile
CHANGED
|
@@ -4,7 +4,7 @@
|
|
| 4 |
# =============================================================================
|
| 5 |
# Stage 1: Build React Frontend
|
| 6 |
# =============================================================================
|
| 7 |
-
FROM node:
|
| 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 |
-
#
|
| 61 |
-
|
|
|
|
|
|
| 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(
|
| 60 |
-
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
-
import
|
| 6 |
-
import
|
| 7 |
-
from
|
| 8 |
-
from datetime import datetime
|
| 9 |
from typing import Optional
|
| 10 |
|
|
|
|
|
|
|
| 11 |
from .config import get_settings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
-
|
| 14 |
|
| 15 |
|
| 16 |
-
def
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
async def init_database():
|
| 24 |
-
"""
|
| 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 |
-
#
|
|
|
|
|
|
|
| 92 |
|
| 93 |
-
async def create_user(
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
)
|
|
|
|
| 101 |
await db.commit()
|
| 102 |
-
|
|
|
|
| 103 |
|
| 104 |
|
| 105 |
async def get_user_by_email(email: str) -> Optional[dict]:
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 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 |
-
|
| 116 |
-
|
| 117 |
-
|
| 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 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
await db.commit()
|
| 129 |
|
| 130 |
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
|
| 133 |
async def create_session(user_id: int, title: str = "Untitled Session") -> int:
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 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 |
-
|
|
|
|
| 143 |
|
| 144 |
|
| 145 |
async def get_sessions(user_id: int) -> list[dict]:
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 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 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
|
|
|
| 163 |
|
| 164 |
|
| 165 |
async def update_session_status(session_id: int, status: str):
|
| 166 |
-
|
| 167 |
-
now = datetime.utcnow().isoformat()
|
| 168 |
-
async with aiosqlite.connect(db_path) as db:
|
| 169 |
await db.execute(
|
| 170 |
-
|
| 171 |
-
(status, now, session_id),
|
| 172 |
)
|
| 173 |
await db.commit()
|
| 174 |
|
| 175 |
|
| 176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
|
|
|
| 187 |
)
|
|
|
|
| 188 |
await db.commit()
|
| 189 |
-
|
|
|
|
| 190 |
|
| 191 |
|
| 192 |
async def get_parsed_data(session_id: int) -> list[dict]:
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
)
|
| 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 |
-
|
| 211 |
-
|
| 212 |
if data_type:
|
| 213 |
-
|
| 214 |
-
|
| 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
|
| 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 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
(
|
| 247 |
-
)
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
return None
|
| 251 |
-
return json.loads(row["structured_data"])
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
# ---- Prompt CRUD ----
|
| 255 |
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
await db.commit()
|
| 265 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
|
| 267 |
|
| 268 |
async def get_prompts(user_id: int) -> list[dict]:
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 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 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
|
|
|
|
|
|
|
|
|
| 291 |
|
| 292 |
|
| 293 |
async def get_prompt(prompt_id: int) -> Optional[dict]:
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
|
|
|
| 300 |
|
| 301 |
|
| 302 |
async def update_prompt(prompt_id: int, name: str, content: str):
|
| 303 |
-
|
| 304 |
-
now = datetime.utcnow().isoformat()
|
| 305 |
-
async with aiosqlite.connect(db_path) as db:
|
| 306 |
await db.execute(
|
| 307 |
-
|
| 308 |
-
(
|
|
|
|
| 309 |
)
|
| 310 |
await db.commit()
|
| 311 |
|
| 312 |
|
| 313 |
async def delete_prompt(prompt_id: int):
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
await db.execute("DELETE FROM prompts WHERE id = ?", (prompt_id,))
|
| 317 |
await db.commit()
|
| 318 |
|
| 319 |
|
| 320 |
-
#
|
|
|
|
|
|
|
| 321 |
|
| 322 |
-
async def save_report(
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
)
|
|
|
|
| 330 |
await db.commit()
|
| 331 |
-
|
|
|
|
| 332 |
|
| 333 |
|
| 334 |
async def get_reports(session_id: int) -> list[dict]:
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 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 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 .
|
|
|
|
|
|
|
|
|
|
| 37 |
from .file_processor import process_uploaded_files
|
| 38 |
-
from .answer_grid import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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":
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, "
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 2 |
-
# Copy to .env for local development
|
| 3 |
-
# For HF Spaces, add these as Secrets in the Space settings
|
| 4 |
|
| 5 |
# =============================================================================
|
| 6 |
-
#
|
| 7 |
# =============================================================================
|
| 8 |
-
|
| 9 |
-
# OpenAI API Key (required for ChatKit)
|
| 10 |
OPENAI_API_KEY=sk-your-openai-api-key
|
| 11 |
|
| 12 |
# =============================================================================
|
| 13 |
-
#
|
| 14 |
# =============================================================================
|
|
|
|
|
|
|
| 15 |
|
| 16 |
-
#
|
| 17 |
-
|
| 18 |
-
GOOGLE_CLIENT_SECRET=GOCSPX-your-client-secret
|
| 19 |
|
| 20 |
-
#
|
| 21 |
-
#
|
| 22 |
-
|
| 23 |
-
GOOGLE_REDIRECT_URI=http://localhost:8000/auth/callback
|
| 24 |
|
| 25 |
-
#
|
| 26 |
-
|
| 27 |
-
|
|
|
|
| 28 |
|
| 29 |
-
#
|
| 30 |
-
|
| 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 |
-
#
|
| 39 |
# =============================================================================
|
|
|
|
|
|
|
| 40 |
|
| 41 |
-
#
|
| 42 |
-
|
| 43 |
|
| 44 |
# =============================================================================
|
| 45 |
-
#
|
| 46 |
# =============================================================================
|
| 47 |
-
|
| 48 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 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 |
+
}
|