Spaces:
Sleeping
Sleeping
taboola commited on
Commit ·
7d40f39
1
Parent(s): 38a90da
Revert "add teacher retention, feedback-loop, and issue-report analytics to admin backend"
Browse filesThis reverts commit c5b36039af43f2a5fead8efe0ba08ca4b3b49db3.
- chatkit/backend/alembic/versions/0015_issue_reports.py +0 -54
- chatkit/backend/app/admin.py +0 -32
- chatkit/backend/app/auth.py +2 -2
- chatkit/backend/app/config.py +0 -15
- chatkit/backend/app/database.py +0 -232
- chatkit/backend/app/main.py +0 -52
- chatkit/backend/app/models.py +0 -23
- chatkit/backend/app/notifications.py +0 -68
- chatkit/backend/tests/test_database.py +0 -122
chatkit/backend/alembic/versions/0015_issue_reports.py
DELETED
|
@@ -1,54 +0,0 @@
|
|
| 1 |
-
"""add issue_reports table
|
| 2 |
-
|
| 3 |
-
Revision ID: 0015_issue_reports
|
| 4 |
-
Revises: 0014_teacher_vocabulary
|
| 5 |
-
Create Date: 2026-07-22
|
| 6 |
-
|
| 7 |
-
Backs the "report an issue" form: submissions are emailed to admins and
|
| 8 |
-
also persisted here so they show up as a dismissible alert in the admin
|
| 9 |
-
dashboard until dismissed.
|
| 10 |
-
"""
|
| 11 |
-
from alembic import op
|
| 12 |
-
import sqlalchemy as sa
|
| 13 |
-
|
| 14 |
-
from app.db import active_schema
|
| 15 |
-
|
| 16 |
-
revision = "0015_issue_reports"
|
| 17 |
-
down_revision = "0014_teacher_vocabulary"
|
| 18 |
-
branch_labels = None
|
| 19 |
-
depends_on = None
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
def upgrade() -> None:
|
| 23 |
-
schema = active_schema()
|
| 24 |
-
inspector = sa.inspect(op.get_bind())
|
| 25 |
-
tables = inspector.get_table_names(schema=schema)
|
| 26 |
-
|
| 27 |
-
if "issue_reports" not in tables:
|
| 28 |
-
op.create_table(
|
| 29 |
-
"issue_reports",
|
| 30 |
-
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
| 31 |
-
sa.Column("reporter_email", sa.String(255), nullable=False),
|
| 32 |
-
sa.Column("category", sa.String(32), nullable=False, server_default="bug"),
|
| 33 |
-
sa.Column("subject", sa.String(255), nullable=False),
|
| 34 |
-
sa.Column("description", sa.Text(), nullable=False),
|
| 35 |
-
sa.Column("page_context", sa.String(255), server_default=""),
|
| 36 |
-
sa.Column("status", sa.String(16), nullable=False, server_default="open"),
|
| 37 |
-
sa.Column(
|
| 38 |
-
"created_at",
|
| 39 |
-
sa.DateTime(timezone=True),
|
| 40 |
-
server_default=sa.func.now(),
|
| 41 |
-
nullable=False,
|
| 42 |
-
),
|
| 43 |
-
sa.Column("dismissed_at", sa.DateTime(timezone=True), nullable=True),
|
| 44 |
-
schema=schema,
|
| 45 |
-
)
|
| 46 |
-
op.create_index(
|
| 47 |
-
"ix_issue_reports_created_at", "issue_reports", ["created_at"], schema=schema
|
| 48 |
-
)
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
def downgrade() -> None:
|
| 52 |
-
schema = active_schema()
|
| 53 |
-
op.drop_index("ix_issue_reports_created_at", table_name="issue_reports", schema=schema)
|
| 54 |
-
op.drop_table("issue_reports", schema=schema)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
chatkit/backend/app/admin.py
CHANGED
|
@@ -23,11 +23,9 @@ from .database import (
|
|
| 23 |
admin_list_students,
|
| 24 |
delete_session,
|
| 25 |
delete_user,
|
| 26 |
-
dismiss_issue_report,
|
| 27 |
get_active_users,
|
| 28 |
get_answer_grid,
|
| 29 |
get_assessments_processed,
|
| 30 |
-
get_feedback_loop_funnel,
|
| 31 |
get_parsed_data,
|
| 32 |
get_practice_quiz_funnel,
|
| 33 |
get_quiz_results,
|
|
@@ -38,10 +36,8 @@ from .database import (
|
|
| 38 |
get_student,
|
| 39 |
get_student_improvement_summary,
|
| 40 |
get_student_timeline,
|
| 41 |
-
get_teacher_retention,
|
| 42 |
get_teacher_vocabulary,
|
| 43 |
list_all_users,
|
| 44 |
-
list_open_issue_reports,
|
| 45 |
set_teacher_vocabulary,
|
| 46 |
set_user_admin,
|
| 47 |
wipe_quiz_data,
|
|
@@ -105,34 +101,6 @@ async def dashboard_student_improvement(_admin=Depends(get_current_admin)):
|
|
| 105 |
return await get_student_improvement_summary()
|
| 106 |
|
| 107 |
|
| 108 |
-
@router.get("/analytics/teacher-retention")
|
| 109 |
-
async def dashboard_teacher_retention(_admin=Depends(get_current_admin)):
|
| 110 |
-
return await get_teacher_retention()
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
@router.get("/analytics/feedback-loop")
|
| 114 |
-
async def dashboard_feedback_loop(_admin=Depends(get_current_admin)):
|
| 115 |
-
return await get_feedback_loop_funnel()
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
# ---- Issue reports (admin only) ----
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
@router.get("/issues")
|
| 122 |
-
async def dashboard_list_issues(_admin=Depends(get_current_admin)):
|
| 123 |
-
return {"issues": await list_open_issue_reports()}
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
@router.post("/issues/{issue_id}/dismiss")
|
| 127 |
-
async def dashboard_dismiss_issue(issue_id: int, _admin=Depends(get_current_admin)):
|
| 128 |
-
ok = await dismiss_issue_report(issue_id)
|
| 129 |
-
if not ok:
|
| 130 |
-
raise HTTPException(
|
| 131 |
-
status_code=404, detail="Issue not found or already dismissed"
|
| 132 |
-
)
|
| 133 |
-
return {"status": "dismissed"}
|
| 134 |
-
|
| 135 |
-
|
| 136 |
# ---- User management (admin only) ----
|
| 137 |
|
| 138 |
|
|
|
|
| 23 |
admin_list_students,
|
| 24 |
delete_session,
|
| 25 |
delete_user,
|
|
|
|
| 26 |
get_active_users,
|
| 27 |
get_answer_grid,
|
| 28 |
get_assessments_processed,
|
|
|
|
| 29 |
get_parsed_data,
|
| 30 |
get_practice_quiz_funnel,
|
| 31 |
get_quiz_results,
|
|
|
|
| 36 |
get_student,
|
| 37 |
get_student_improvement_summary,
|
| 38 |
get_student_timeline,
|
|
|
|
| 39 |
get_teacher_vocabulary,
|
| 40 |
list_all_users,
|
|
|
|
| 41 |
set_teacher_vocabulary,
|
| 42 |
set_user_admin,
|
| 43 |
wipe_quiz_data,
|
|
|
|
| 101 |
return await get_student_improvement_summary()
|
| 102 |
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
# ---- User management (admin only) ----
|
| 105 |
|
| 106 |
|
chatkit/backend/app/auth.py
CHANGED
|
@@ -28,11 +28,11 @@ security = HTTPBearer(auto_error=False)
|
|
| 28 |
|
| 29 |
# Grants admin access to this account regardless of the `is_admin` flag in
|
| 30 |
# Supabase — so it doesn't need a manual DB update to stay an admin.
|
| 31 |
-
|
| 32 |
|
| 33 |
|
| 34 |
def _apply_admin_override(user: dict) -> dict:
|
| 35 |
-
if user.get("email") ==
|
| 36 |
return {**user, "is_admin": True}
|
| 37 |
return user
|
| 38 |
|
|
|
|
| 28 |
|
| 29 |
# Grants admin access to this account regardless of the `is_admin` flag in
|
| 30 |
# Supabase — so it doesn't need a manual DB update to stay an admin.
|
| 31 |
+
_EXTRA_ADMIN_EMAIL = "astraealiu44@gmail.com"
|
| 32 |
|
| 33 |
|
| 34 |
def _apply_admin_override(user: dict) -> dict:
|
| 35 |
+
if user.get("email") == _EXTRA_ADMIN_EMAIL and not user.get("is_admin"):
|
| 36 |
return {**user, "is_admin": True}
|
| 37 |
return user
|
| 38 |
|
chatkit/backend/app/config.py
CHANGED
|
@@ -51,16 +51,6 @@ class Settings(BaseModel):
|
|
| 51 |
# Frontend URL for redirects
|
| 52 |
frontend_url: str = "http://localhost:3000"
|
| 53 |
|
| 54 |
-
# SMTP — used only to email admins when a "report an issue" form is
|
| 55 |
-
# submitted (see app/notifications.py). Left blank, sending is skipped
|
| 56 |
-
# (logged, not raised) so the report still saves and shows up in the
|
| 57 |
-
# admin dashboard even with no mail server configured.
|
| 58 |
-
smtp_host: str = ""
|
| 59 |
-
smtp_port: int = 587
|
| 60 |
-
smtp_user: str = ""
|
| 61 |
-
smtp_password: str = ""
|
| 62 |
-
smtp_from: str = ""
|
| 63 |
-
|
| 64 |
|
| 65 |
@lru_cache
|
| 66 |
def get_settings() -> Settings:
|
|
@@ -86,9 +76,4 @@ def get_settings() -> Settings:
|
|
| 86 |
upload_dir=os.getenv("UPLOAD_DIR", ""),
|
| 87 |
max_file_size_mb=int(os.getenv("MAX_FILE_SIZE_MB", "20")),
|
| 88 |
frontend_url=os.getenv("FRONTEND_URL", "http://localhost:3000"),
|
| 89 |
-
smtp_host=os.getenv("SMTP_HOST", ""),
|
| 90 |
-
smtp_port=int(os.getenv("SMTP_PORT", "587")),
|
| 91 |
-
smtp_user=os.getenv("SMTP_USER", ""),
|
| 92 |
-
smtp_password=os.getenv("SMTP_PASSWORD", ""),
|
| 93 |
-
smtp_from=os.getenv("SMTP_FROM", ""),
|
| 94 |
)
|
|
|
|
| 51 |
# Frontend URL for redirects
|
| 52 |
frontend_url: str = "http://localhost:3000"
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
@lru_cache
|
| 56 |
def get_settings() -> Settings:
|
|
|
|
| 76 |
upload_dir=os.getenv("UPLOAD_DIR", ""),
|
| 77 |
max_file_size_mb=int(os.getenv("MAX_FILE_SIZE_MB", "20")),
|
| 78 |
frontend_url=os.getenv("FRONTEND_URL", "http://localhost:3000"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
)
|
chatkit/backend/app/database.py
CHANGED
|
@@ -27,7 +27,6 @@ from .taxonomy import MAIN_CATEGORIES
|
|
| 27 |
from .models import (
|
| 28 |
AnswerGrid as AnswerGridRecord,
|
| 29 |
Base,
|
| 30 |
-
IssueReport,
|
| 31 |
LoginEvent,
|
| 32 |
ParsedData,
|
| 33 |
PracticeQuiz,
|
|
@@ -46,16 +45,6 @@ def _now() -> datetime:
|
|
| 46 |
return datetime.now(timezone.utc)
|
| 47 |
|
| 48 |
|
| 49 |
-
def _aware(dt: datetime) -> datetime:
|
| 50 |
-
"""Postgres (DateTime(timezone=True)) round-trips tz-aware datetimes;
|
| 51 |
-
SQLite always hands back naive ones regardless of column type. Anything
|
| 52 |
-
comparing a DB-fetched datetime against another in plain Python (not a
|
| 53 |
-
SQL WHERE clause, which each dialect handles consistently on its own)
|
| 54 |
-
needs both sides on the same footing — assume naive values are UTC,
|
| 55 |
-
since every datetime this app writes is generated via _now()."""
|
| 56 |
-
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
| 57 |
-
|
| 58 |
-
|
| 59 |
def _row(obj) -> dict:
|
| 60 |
"""Serialize an ORM object to a plain dict of its columns."""
|
| 61 |
return {c.key: getattr(obj, c.key) for c in obj.__table__.columns}
|
|
@@ -174,60 +163,6 @@ async def set_user_admin(user_id: int, is_admin: bool):
|
|
| 174 |
await db.commit()
|
| 175 |
|
| 176 |
|
| 177 |
-
async def get_admin_emails() -> list[str]:
|
| 178 |
-
"""Every teacher with is_admin=True in the DB — used as the recipient
|
| 179 |
-
list for issue-report notifications. Doesn't include email-based admin
|
| 180 |
-
overrides granted only in code (see auth._apply_admin_override); callers
|
| 181 |
-
that need those too should merge them in separately."""
|
| 182 |
-
async with get_sessionmaker()() as db:
|
| 183 |
-
res = await db.execute(select(Teacher.email).where(Teacher.is_admin.is_(True)))
|
| 184 |
-
return [e for (e,) in res.all()]
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
async def create_issue_report(
|
| 188 |
-
reporter_email: str,
|
| 189 |
-
category: str,
|
| 190 |
-
subject: str,
|
| 191 |
-
description: str,
|
| 192 |
-
page_context: str = "",
|
| 193 |
-
) -> dict:
|
| 194 |
-
async with get_sessionmaker()() as db:
|
| 195 |
-
report = IssueReport(
|
| 196 |
-
reporter_email=reporter_email,
|
| 197 |
-
category=category,
|
| 198 |
-
subject=subject,
|
| 199 |
-
description=description,
|
| 200 |
-
page_context=page_context,
|
| 201 |
-
)
|
| 202 |
-
db.add(report)
|
| 203 |
-
await db.flush()
|
| 204 |
-
await db.refresh(report)
|
| 205 |
-
row = _row(report)
|
| 206 |
-
await db.commit()
|
| 207 |
-
return row
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
async def list_open_issue_reports() -> list[dict]:
|
| 211 |
-
async with get_sessionmaker()() as db:
|
| 212 |
-
res = await db.execute(
|
| 213 |
-
select(IssueReport)
|
| 214 |
-
.where(IssueReport.status == "open")
|
| 215 |
-
.order_by(IssueReport.created_at.desc())
|
| 216 |
-
)
|
| 217 |
-
return [_row(r) for r in res.scalars().all()]
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
async def dismiss_issue_report(report_id: int) -> bool:
|
| 221 |
-
async with get_sessionmaker()() as db:
|
| 222 |
-
res = await db.execute(
|
| 223 |
-
update(IssueReport)
|
| 224 |
-
.where(IssueReport.id == report_id, IssueReport.status == "open")
|
| 225 |
-
.values(status="dismissed", dismissed_at=_now())
|
| 226 |
-
)
|
| 227 |
-
await db.commit()
|
| 228 |
-
return res.rowcount > 0 # type: ignore[attr-defined]
|
| 229 |
-
|
| 230 |
-
|
| 231 |
async def list_all_users() -> list[dict]:
|
| 232 |
"""Admin: all teachers (without password hashes), with per-teacher counts."""
|
| 233 |
async with get_sessionmaker()() as db:
|
|
@@ -1305,50 +1240,6 @@ async def get_active_users(weeks: int = 12) -> dict:
|
|
| 1305 |
}
|
| 1306 |
|
| 1307 |
|
| 1308 |
-
async def get_teacher_retention() -> dict:
|
| 1309 |
-
"""Of teachers whose account is old enough to judge, what share came back
|
| 1310 |
-
and logged in again *after* their first week / first month — not just
|
| 1311 |
-
during initial signup/onboarding. Shows sustained use rather than
|
| 1312 |
-
one-time testing.
|
| 1313 |
-
|
| 1314 |
-
A teacher only counts toward a window's denominator once their account
|
| 1315 |
-
has existed at least that long (a 3-day-old account can't yet prove or
|
| 1316 |
-
disprove week-1 retention), and toward the numerator if any login_event
|
| 1317 |
-
for them lands strictly after `created_at + window`.
|
| 1318 |
-
"""
|
| 1319 |
-
now = _now()
|
| 1320 |
-
async with get_sessionmaker()() as db:
|
| 1321 |
-
teachers_res = await db.execute(select(Teacher.id, Teacher.created_at))
|
| 1322 |
-
teachers = [(tid, _aware(created_at)) for tid, created_at in teachers_res.all()]
|
| 1323 |
-
|
| 1324 |
-
logins_res = await db.execute(
|
| 1325 |
-
select(LoginEvent.user_id, LoginEvent.created_at).where(
|
| 1326 |
-
LoginEvent.role == "teacher"
|
| 1327 |
-
)
|
| 1328 |
-
)
|
| 1329 |
-
logins_by_teacher: dict[int, list[datetime]] = {}
|
| 1330 |
-
for user_id, created_at in logins_res.all():
|
| 1331 |
-
logins_by_teacher.setdefault(user_id, []).append(_aware(created_at))
|
| 1332 |
-
|
| 1333 |
-
def window_stats(days: int) -> dict:
|
| 1334 |
-
eligible = 0
|
| 1335 |
-
retained = 0
|
| 1336 |
-
for teacher_id, created_at in teachers:
|
| 1337 |
-
cutoff = created_at + timedelta(days=days)
|
| 1338 |
-
if cutoff > now:
|
| 1339 |
-
continue # account not old enough to judge this window yet
|
| 1340 |
-
eligible += 1
|
| 1341 |
-
if any(t > cutoff for t in logins_by_teacher.get(teacher_id, [])):
|
| 1342 |
-
retained += 1
|
| 1343 |
-
pct = round(100 * retained / eligible, 1) if eligible else 0.0
|
| 1344 |
-
return {"eligible": eligible, "retained": retained, "pct": pct}
|
| 1345 |
-
|
| 1346 |
-
return {
|
| 1347 |
-
"week": window_stats(7),
|
| 1348 |
-
"month": window_stats(30),
|
| 1349 |
-
}
|
| 1350 |
-
|
| 1351 |
-
|
| 1352 |
async def get_assessments_processed(months: int = 6) -> dict:
|
| 1353 |
"""Monthly counts of exams uploaded (saved quizzes) and student reports
|
| 1354 |
generated, for the admin analytics bar chart."""
|
|
@@ -1428,129 +1319,6 @@ async def get_practice_quiz_funnel(teacher_id: Optional[int] = None) -> dict:
|
|
| 1428 |
}
|
| 1429 |
|
| 1430 |
|
| 1431 |
-
async def get_feedback_loop_funnel(teacher_id: Optional[int] = None) -> dict:
|
| 1432 |
-
"""Exam result → weakness identified → practice generated → practice
|
| 1433 |
-
completed → later reassessment, for the admin funnel chart.
|
| 1434 |
-
|
| 1435 |
-
The unit here is one student's result on one real exam (a
|
| 1436 |
-
StudentResult row on a Quiz with status="saved" and no
|
| 1437 |
-
source_practice_quiz_id — i.e. not the synthetic quiz row a completed
|
| 1438 |
-
practice quiz feeds into the dashboard as). PracticeQuiz has no FK back
|
| 1439 |
-
to the exam that triggered it, so each stage is correlated by student +
|
| 1440 |
-
timestamp ordering instead: the practice quiz counted for a given exam
|
| 1441 |
-
result is the earliest one generated for that student after the exam,
|
| 1442 |
-
and a "reassessment" is any later real exam for that same student after
|
| 1443 |
-
that practice was completed.
|
| 1444 |
-
"""
|
| 1445 |
-
async with get_sessionmaker()() as db:
|
| 1446 |
-
quiz_stmt = select(Quiz.id, Quiz.created_at).where(
|
| 1447 |
-
Quiz.status == "saved", Quiz.source_practice_quiz_id.is_(None)
|
| 1448 |
-
)
|
| 1449 |
-
if teacher_id is not None:
|
| 1450 |
-
quiz_stmt = quiz_stmt.where(Quiz.teacher_id == teacher_id)
|
| 1451 |
-
quiz_res = await db.execute(quiz_stmt)
|
| 1452 |
-
quiz_created_at: dict[int, datetime] = {
|
| 1453 |
-
qid: _aware(dt) for qid, dt in quiz_res.all()
|
| 1454 |
-
}
|
| 1455 |
-
|
| 1456 |
-
empty = {
|
| 1457 |
-
"uploaded": 0,
|
| 1458 |
-
"weakness_identified": 0,
|
| 1459 |
-
"practice_generated": 0,
|
| 1460 |
-
"practice_completed": 0,
|
| 1461 |
-
"reassessed": 0,
|
| 1462 |
-
}
|
| 1463 |
-
if not quiz_created_at:
|
| 1464 |
-
return empty
|
| 1465 |
-
|
| 1466 |
-
result_res = await db.execute(
|
| 1467 |
-
select(
|
| 1468 |
-
StudentResult.quiz_id,
|
| 1469 |
-
StudentResult.student_id,
|
| 1470 |
-
StudentResult.weaknesses,
|
| 1471 |
-
).where(StudentResult.quiz_id.in_(quiz_created_at.keys()))
|
| 1472 |
-
)
|
| 1473 |
-
results = result_res.all()
|
| 1474 |
-
if not results:
|
| 1475 |
-
return empty
|
| 1476 |
-
|
| 1477 |
-
student_ids = {sid for _, sid, _ in results}
|
| 1478 |
-
|
| 1479 |
-
pq_stmt = select(
|
| 1480 |
-
PracticeQuiz.student_id,
|
| 1481 |
-
PracticeQuiz.created_at,
|
| 1482 |
-
PracticeQuiz.completed_at,
|
| 1483 |
-
PracticeQuiz.status,
|
| 1484 |
-
).where(PracticeQuiz.student_id.in_(student_ids))
|
| 1485 |
-
if teacher_id is not None:
|
| 1486 |
-
pq_stmt = pq_stmt.where(PracticeQuiz.teacher_id == teacher_id)
|
| 1487 |
-
pq_res = await db.execute(pq_stmt)
|
| 1488 |
-
practice_by_student: dict[int, list[tuple]] = {}
|
| 1489 |
-
for sid, created_at, completed_at, status in pq_res.all():
|
| 1490 |
-
practice_by_student.setdefault(sid, []).append(
|
| 1491 |
-
(
|
| 1492 |
-
_aware(created_at),
|
| 1493 |
-
_aware(completed_at) if completed_at else None,
|
| 1494 |
-
status,
|
| 1495 |
-
)
|
| 1496 |
-
)
|
| 1497 |
-
|
| 1498 |
-
# Every real exam per student (quiz_id kept so a student's own
|
| 1499 |
-
# triggering exam is never counted as its own "later" reassessment).
|
| 1500 |
-
exam_stmt = (
|
| 1501 |
-
select(StudentResult.student_id, Quiz.id, Quiz.created_at)
|
| 1502 |
-
.join(Quiz, StudentResult.quiz_id == Quiz.id)
|
| 1503 |
-
.where(Quiz.status == "saved", Quiz.source_practice_quiz_id.is_(None))
|
| 1504 |
-
)
|
| 1505 |
-
if teacher_id is not None:
|
| 1506 |
-
exam_stmt = exam_stmt.where(Quiz.teacher_id == teacher_id)
|
| 1507 |
-
exam_res = await db.execute(exam_stmt)
|
| 1508 |
-
exams_by_student: dict[int, list[tuple]] = {}
|
| 1509 |
-
for sid, qid, created_at in exam_res.all():
|
| 1510 |
-
exams_by_student.setdefault(sid, []).append((qid, _aware(created_at)))
|
| 1511 |
-
|
| 1512 |
-
uploaded = len(results)
|
| 1513 |
-
weakness_identified = 0
|
| 1514 |
-
practice_generated = 0
|
| 1515 |
-
practice_completed = 0
|
| 1516 |
-
reassessed = 0
|
| 1517 |
-
|
| 1518 |
-
for quiz_id, student_id, weaknesses in results:
|
| 1519 |
-
if not (weaknesses or "").strip():
|
| 1520 |
-
continue
|
| 1521 |
-
weakness_identified += 1
|
| 1522 |
-
|
| 1523 |
-
quiz_time = quiz_created_at[quiz_id]
|
| 1524 |
-
practices_after = sorted(
|
| 1525 |
-
(p for p in practice_by_student.get(student_id, []) if p[0] > quiz_time),
|
| 1526 |
-
key=lambda p: p[0],
|
| 1527 |
-
)
|
| 1528 |
-
if not practices_after:
|
| 1529 |
-
continue
|
| 1530 |
-
practice_generated += 1
|
| 1531 |
-
|
| 1532 |
-
_, completed_at, status = practices_after[0]
|
| 1533 |
-
if status != "completed" or completed_at is None:
|
| 1534 |
-
continue
|
| 1535 |
-
practice_completed += 1
|
| 1536 |
-
|
| 1537 |
-
later_exams = [
|
| 1538 |
-
t
|
| 1539 |
-
for other_qid, t in exams_by_student.get(student_id, [])
|
| 1540 |
-
if other_qid != quiz_id and t > completed_at
|
| 1541 |
-
]
|
| 1542 |
-
if later_exams:
|
| 1543 |
-
reassessed += 1
|
| 1544 |
-
|
| 1545 |
-
return {
|
| 1546 |
-
"uploaded": uploaded,
|
| 1547 |
-
"weakness_identified": weakness_identified,
|
| 1548 |
-
"practice_generated": practice_generated,
|
| 1549 |
-
"practice_completed": practice_completed,
|
| 1550 |
-
"reassessed": reassessed,
|
| 1551 |
-
}
|
| 1552 |
-
|
| 1553 |
-
|
| 1554 |
async def get_all_students_category_history(
|
| 1555 |
teacher_id: Optional[int] = None,
|
| 1556 |
) -> dict[int, dict[str, list[dict]]]:
|
|
|
|
| 27 |
from .models import (
|
| 28 |
AnswerGrid as AnswerGridRecord,
|
| 29 |
Base,
|
|
|
|
| 30 |
LoginEvent,
|
| 31 |
ParsedData,
|
| 32 |
PracticeQuiz,
|
|
|
|
| 45 |
return datetime.now(timezone.utc)
|
| 46 |
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
def _row(obj) -> dict:
|
| 49 |
"""Serialize an ORM object to a plain dict of its columns."""
|
| 50 |
return {c.key: getattr(obj, c.key) for c in obj.__table__.columns}
|
|
|
|
| 163 |
await db.commit()
|
| 164 |
|
| 165 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
async def list_all_users() -> list[dict]:
|
| 167 |
"""Admin: all teachers (without password hashes), with per-teacher counts."""
|
| 168 |
async with get_sessionmaker()() as db:
|
|
|
|
| 1240 |
}
|
| 1241 |
|
| 1242 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1243 |
async def get_assessments_processed(months: int = 6) -> dict:
|
| 1244 |
"""Monthly counts of exams uploaded (saved quizzes) and student reports
|
| 1245 |
generated, for the admin analytics bar chart."""
|
|
|
|
| 1319 |
}
|
| 1320 |
|
| 1321 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1322 |
async def get_all_students_category_history(
|
| 1323 |
teacher_id: Optional[int] = None,
|
| 1324 |
) -> dict[int, dict[str, list[dict]]]:
|
chatkit/backend/app/main.py
CHANGED
|
@@ -58,8 +58,6 @@ from .database import (
|
|
| 58 |
list_practice_quizzes,
|
| 59 |
mark_practice_quiz_opened,
|
| 60 |
submit_practice_quiz,
|
| 61 |
-
create_issue_report,
|
| 62 |
-
get_admin_emails,
|
| 63 |
)
|
| 64 |
from .auth import (
|
| 65 |
get_current_user,
|
|
@@ -71,11 +69,9 @@ from .auth import (
|
|
| 71 |
get_student_from_raw_token,
|
| 72 |
login_student,
|
| 73 |
public_student,
|
| 74 |
-
EXTRA_ADMIN_EMAIL,
|
| 75 |
)
|
| 76 |
from .config import get_settings
|
| 77 |
from .crypto import encrypt
|
| 78 |
-
from .notifications import send_issue_report_email
|
| 79 |
from .student_credentials import (
|
| 80 |
ensure_student_login,
|
| 81 |
generate_unique_username,
|
|
@@ -1094,54 +1090,6 @@ async def api_rewrite_question(
|
|
| 1094 |
raise HTTPException(status_code=500, detail=f"Rewrite failed: {str(e)}")
|
| 1095 |
|
| 1096 |
|
| 1097 |
-
# =============================================================================
|
| 1098 |
-
# Issue Reports ("report an issue" form — any logged-in teacher)
|
| 1099 |
-
# =============================================================================
|
| 1100 |
-
|
| 1101 |
-
_ISSUE_CATEGORIES = {"bug", "feature", "other"}
|
| 1102 |
-
|
| 1103 |
-
|
| 1104 |
-
class IssueReportRequest(BaseModel):
|
| 1105 |
-
category: str = "bug"
|
| 1106 |
-
subject: str
|
| 1107 |
-
description: str
|
| 1108 |
-
page_context: str = ""
|
| 1109 |
-
|
| 1110 |
-
|
| 1111 |
-
@app.post("/api/issues")
|
| 1112 |
-
async def api_submit_issue_report(
|
| 1113 |
-
body: IssueReportRequest, user=Depends(get_current_user)
|
| 1114 |
-
):
|
| 1115 |
-
if body.category not in _ISSUE_CATEGORIES:
|
| 1116 |
-
raise HTTPException(status_code=400, detail="Invalid category.")
|
| 1117 |
-
if not body.subject.strip() or not body.description.strip():
|
| 1118 |
-
raise HTTPException(
|
| 1119 |
-
status_code=400, detail="Subject and description are required."
|
| 1120 |
-
)
|
| 1121 |
-
|
| 1122 |
-
report = await create_issue_report(
|
| 1123 |
-
user["email"],
|
| 1124 |
-
body.category,
|
| 1125 |
-
body.subject.strip(),
|
| 1126 |
-
body.description.strip(),
|
| 1127 |
-
body.page_context,
|
| 1128 |
-
)
|
| 1129 |
-
|
| 1130 |
-
# DB-flagged admins plus the code-level admin override (see
|
| 1131 |
-
# auth._apply_admin_override) — that account is an admin in the app but
|
| 1132 |
-
# was never flipped in the DB, so get_admin_emails() alone would miss it.
|
| 1133 |
-
admin_emails = {*await get_admin_emails(), EXTRA_ADMIN_EMAIL}
|
| 1134 |
-
await send_issue_report_email(
|
| 1135 |
-
sorted(admin_emails),
|
| 1136 |
-
user["email"],
|
| 1137 |
-
body.category,
|
| 1138 |
-
body.subject.strip(),
|
| 1139 |
-
body.description.strip(),
|
| 1140 |
-
body.page_context,
|
| 1141 |
-
)
|
| 1142 |
-
return {"status": "submitted", "id": report["id"]}
|
| 1143 |
-
|
| 1144 |
-
|
| 1145 |
# =============================================================================
|
| 1146 |
# Practice Quizzes (individualized, student-facing, single-attempt, no login)
|
| 1147 |
# =============================================================================
|
|
|
|
| 58 |
list_practice_quizzes,
|
| 59 |
mark_practice_quiz_opened,
|
| 60 |
submit_practice_quiz,
|
|
|
|
|
|
|
| 61 |
)
|
| 62 |
from .auth import (
|
| 63 |
get_current_user,
|
|
|
|
| 69 |
get_student_from_raw_token,
|
| 70 |
login_student,
|
| 71 |
public_student,
|
|
|
|
| 72 |
)
|
| 73 |
from .config import get_settings
|
| 74 |
from .crypto import encrypt
|
|
|
|
| 75 |
from .student_credentials import (
|
| 76 |
ensure_student_login,
|
| 77 |
generate_unique_username,
|
|
|
|
| 1090 |
raise HTTPException(status_code=500, detail=f"Rewrite failed: {str(e)}")
|
| 1091 |
|
| 1092 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1093 |
# =============================================================================
|
| 1094 |
# Practice Quizzes (individualized, student-facing, single-attempt, no login)
|
| 1095 |
# =============================================================================
|
chatkit/backend/app/models.py
CHANGED
|
@@ -339,26 +339,3 @@ class LoginEvent(Base):
|
|
| 339 |
created_at: Mapped[datetime] = mapped_column(
|
| 340 |
DateTime(timezone=True), server_default=func.now(), index=True
|
| 341 |
)
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
class IssueReport(Base):
|
| 345 |
-
"""A "report an issue" submission — emailed to admins on creation and
|
| 346 |
-
surfaced as a dismissible alert in the admin dashboard until an admin
|
| 347 |
-
dismisses it. No FK to teachers: intentionally keeps working even if the
|
| 348 |
-
reporting account is later deleted."""
|
| 349 |
-
|
| 350 |
-
__tablename__ = "issue_reports"
|
| 351 |
-
|
| 352 |
-
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
| 353 |
-
reporter_email: Mapped[str] = mapped_column(String(255), nullable=False)
|
| 354 |
-
category: Mapped[str] = mapped_column(String(32), nullable=False, default="bug")
|
| 355 |
-
subject: Mapped[str] = mapped_column(String(255), nullable=False)
|
| 356 |
-
description: Mapped[str] = mapped_column(Text, nullable=False)
|
| 357 |
-
page_context: Mapped[str] = mapped_column(String(255), default="")
|
| 358 |
-
status: Mapped[str] = mapped_column(String(16), nullable=False, default="open")
|
| 359 |
-
created_at: Mapped[datetime] = mapped_column(
|
| 360 |
-
DateTime(timezone=True), server_default=func.now(), index=True
|
| 361 |
-
)
|
| 362 |
-
dismissed_at: Mapped[Optional[datetime]] = mapped_column(
|
| 363 |
-
DateTime(timezone=True), nullable=True
|
| 364 |
-
)
|
|
|
|
| 339 |
created_at: Mapped[datetime] = mapped_column(
|
| 340 |
DateTime(timezone=True), server_default=func.now(), index=True
|
| 341 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
chatkit/backend/app/notifications.py
DELETED
|
@@ -1,68 +0,0 @@
|
|
| 1 |
-
"""Email notifications. Currently just one: notify admins when a "report an
|
| 2 |
-
issue" form is submitted. Kept separate from app/auth.py's password-related
|
| 3 |
-
crypto and app/database.py's persistence — this module only ever sends mail,
|
| 4 |
-
never touches the DB or auth.
|
| 5 |
-
"""
|
| 6 |
-
|
| 7 |
-
from __future__ import annotations
|
| 8 |
-
|
| 9 |
-
import asyncio
|
| 10 |
-
import logging
|
| 11 |
-
import smtplib
|
| 12 |
-
from email.message import EmailMessage
|
| 13 |
-
|
| 14 |
-
from .config import get_settings
|
| 15 |
-
|
| 16 |
-
logger = logging.getLogger(__name__)
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
def _send_sync(to_addrs: list[str], subject: str, body: str) -> None:
|
| 20 |
-
settings = get_settings()
|
| 21 |
-
msg = EmailMessage()
|
| 22 |
-
msg["Subject"] = subject
|
| 23 |
-
msg["From"] = settings.smtp_from or settings.smtp_user
|
| 24 |
-
msg["To"] = ", ".join(to_addrs)
|
| 25 |
-
msg.set_content(body)
|
| 26 |
-
|
| 27 |
-
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as smtp:
|
| 28 |
-
smtp.starttls()
|
| 29 |
-
if settings.smtp_user:
|
| 30 |
-
smtp.login(settings.smtp_user, settings.smtp_password)
|
| 31 |
-
smtp.send_message(msg)
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
async def send_issue_report_email(
|
| 35 |
-
to_addrs: list[str],
|
| 36 |
-
reporter_email: str,
|
| 37 |
-
category: str,
|
| 38 |
-
subject: str,
|
| 39 |
-
description: str,
|
| 40 |
-
page_context: str,
|
| 41 |
-
) -> None:
|
| 42 |
-
"""Best-effort notification — never raises. A submitted report is
|
| 43 |
-
already saved to the DB and visible in the admin dashboard before this
|
| 44 |
-
runs, so a mail-server hiccup shouldn't turn into a 500 for the person
|
| 45 |
-
reporting the issue."""
|
| 46 |
-
settings = get_settings()
|
| 47 |
-
if not settings.smtp_host or not to_addrs:
|
| 48 |
-
logger.info(
|
| 49 |
-
"Skipping issue-report email (SMTP not configured or no admin recipients): %s",
|
| 50 |
-
subject,
|
| 51 |
-
)
|
| 52 |
-
return
|
| 53 |
-
|
| 54 |
-
body = (
|
| 55 |
-
f"New issue report from {reporter_email}\n"
|
| 56 |
-
f"Category: {category}\n"
|
| 57 |
-
f"Page: {page_context or '(not provided)'}\n\n"
|
| 58 |
-
f"{description}"
|
| 59 |
-
)
|
| 60 |
-
try:
|
| 61 |
-
# smtplib is blocking network I/O — keep it off the event loop the
|
| 62 |
-
# same way bcrypt hashing is (see app/auth.py), so one slow/hung
|
| 63 |
-
# mail-server connection can't stall every other in-flight request.
|
| 64 |
-
await asyncio.to_thread(
|
| 65 |
-
_send_sync, to_addrs, f"[ClassLens] New issue report: {subject}", body
|
| 66 |
-
)
|
| 67 |
-
except Exception as exc:
|
| 68 |
-
logger.warning("Failed to send issue-report email: %s", exc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
chatkit/backend/tests/test_database.py
CHANGED
|
@@ -135,128 +135,6 @@ async def test_stats_and_lists_scope_to_teacher(fresh_db):
|
|
| 135 |
assert len(await database.admin_list_sessions()) == 2 # admin sees both
|
| 136 |
|
| 137 |
|
| 138 |
-
@pytest.mark.unit
|
| 139 |
-
async def test_teacher_retention_windows(fresh_db):
|
| 140 |
-
from datetime import datetime, timedelta, timezone
|
| 141 |
-
|
| 142 |
-
from app.db import get_sessionmaker
|
| 143 |
-
from app.models import LoginEvent, Teacher
|
| 144 |
-
|
| 145 |
-
now = datetime.now(timezone.utc)
|
| 146 |
-
async with get_sessionmaker()() as db:
|
| 147 |
-
old_returning = Teacher(
|
| 148 |
-
email="returning@example.com",
|
| 149 |
-
password_hash="h",
|
| 150 |
-
created_at=now - timedelta(days=40),
|
| 151 |
-
)
|
| 152 |
-
old_one_time = Teacher(
|
| 153 |
-
email="onetime@example.com",
|
| 154 |
-
password_hash="h",
|
| 155 |
-
created_at=now - timedelta(days=40),
|
| 156 |
-
)
|
| 157 |
-
too_new = Teacher(
|
| 158 |
-
email="new@example.com", password_hash="h", created_at=now - timedelta(days=3)
|
| 159 |
-
)
|
| 160 |
-
db.add_all([old_returning, old_one_time, too_new])
|
| 161 |
-
await db.flush()
|
| 162 |
-
# Returning teacher logged in again well after both the week and
|
| 163 |
-
# month cutoffs from their signup.
|
| 164 |
-
db.add(
|
| 165 |
-
LoginEvent(
|
| 166 |
-
role="teacher", user_id=old_returning.id, created_at=now - timedelta(days=2)
|
| 167 |
-
)
|
| 168 |
-
)
|
| 169 |
-
await db.commit()
|
| 170 |
-
|
| 171 |
-
retention = await database.get_teacher_retention()
|
| 172 |
-
# Only the two 40-day-old accounts are eligible for either window — the
|
| 173 |
-
# 3-day-old one hasn't existed long enough to judge yet.
|
| 174 |
-
assert retention["week"]["eligible"] == 2
|
| 175 |
-
assert retention["week"]["retained"] == 1
|
| 176 |
-
assert retention["week"]["pct"] == 50.0
|
| 177 |
-
assert retention["month"]["eligible"] == 2
|
| 178 |
-
assert retention["month"]["retained"] == 1
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
@pytest.mark.unit
|
| 182 |
-
async def test_feedback_loop_funnel_stages(fresh_db):
|
| 183 |
-
from datetime import datetime, timedelta, timezone
|
| 184 |
-
|
| 185 |
-
from app.db import get_sessionmaker
|
| 186 |
-
from app.models import PracticeQuiz, Quiz, StudentResult
|
| 187 |
-
|
| 188 |
-
uid = await database.create_user("t@example.com", "h")
|
| 189 |
-
s1 = await database.match_or_create_student(uid, "Alice")
|
| 190 |
-
s2 = await database.match_or_create_student(uid, "Bob")
|
| 191 |
-
|
| 192 |
-
now = datetime.now(timezone.utc)
|
| 193 |
-
t0 = now - timedelta(days=10)
|
| 194 |
-
async with get_sessionmaker()() as db:
|
| 195 |
-
quiz1 = Quiz(teacher_id=uid, title="Exam 1", status="saved", created_at=t0)
|
| 196 |
-
db.add(quiz1)
|
| 197 |
-
await db.flush()
|
| 198 |
-
db.add_all(
|
| 199 |
-
[
|
| 200 |
-
# Alice: weakness identified, practice generated + completed,
|
| 201 |
-
# then reassessed with a later real exam — completes all 5 stages.
|
| 202 |
-
StudentResult(
|
| 203 |
-
quiz_id=quiz1.id, student_id=s1, score=60.0, weaknesses="verb tense"
|
| 204 |
-
),
|
| 205 |
-
# Bob: no weakness identified — stops at stage 1.
|
| 206 |
-
StudentResult(quiz_id=quiz1.id, student_id=s2, score=95.0, weaknesses=""),
|
| 207 |
-
]
|
| 208 |
-
)
|
| 209 |
-
db.add(
|
| 210 |
-
PracticeQuiz(
|
| 211 |
-
teacher_id=uid,
|
| 212 |
-
student_id=s1,
|
| 213 |
-
share_token="tok1",
|
| 214 |
-
status="completed",
|
| 215 |
-
created_at=t0 + timedelta(days=1),
|
| 216 |
-
completed_at=t0 + timedelta(days=2),
|
| 217 |
-
)
|
| 218 |
-
)
|
| 219 |
-
await db.flush()
|
| 220 |
-
quiz2 = Quiz(
|
| 221 |
-
teacher_id=uid, title="Exam 2", status="saved", created_at=t0 + timedelta(days=3)
|
| 222 |
-
)
|
| 223 |
-
db.add(quiz2)
|
| 224 |
-
await db.flush()
|
| 225 |
-
db.add(StudentResult(quiz_id=quiz2.id, student_id=s1, score=80.0))
|
| 226 |
-
await db.commit()
|
| 227 |
-
|
| 228 |
-
funnel = await database.get_feedback_loop_funnel()
|
| 229 |
-
# Alice+Bob's results on quiz1, plus Alice's reassessment result on quiz2.
|
| 230 |
-
assert funnel["uploaded"] == 3
|
| 231 |
-
assert funnel["weakness_identified"] == 1 # Alice only
|
| 232 |
-
assert funnel["practice_generated"] == 1
|
| 233 |
-
assert funnel["practice_completed"] == 1
|
| 234 |
-
assert funnel["reassessed"] == 1 # Alice's quiz2 came after her practice completed
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
@pytest.mark.unit
|
| 238 |
-
async def test_issue_report_lifecycle(fresh_db):
|
| 239 |
-
admin_id = await database.create_user("admin@example.com", "h")
|
| 240 |
-
await database.set_user_admin(admin_id, True)
|
| 241 |
-
await database.create_user("teacher@example.com", "h")
|
| 242 |
-
|
| 243 |
-
assert await database.get_admin_emails() == ["admin@example.com"]
|
| 244 |
-
|
| 245 |
-
created = await database.create_issue_report(
|
| 246 |
-
"teacher@example.com", "bug", "Upload fails", "Steps to reproduce...", "/dashboard"
|
| 247 |
-
)
|
| 248 |
-
assert created["status"] == "open"
|
| 249 |
-
|
| 250 |
-
open_issues = await database.list_open_issue_reports()
|
| 251 |
-
assert len(open_issues) == 1
|
| 252 |
-
assert open_issues[0]["subject"] == "Upload fails"
|
| 253 |
-
|
| 254 |
-
assert await database.dismiss_issue_report(created["id"]) is True
|
| 255 |
-
assert await database.list_open_issue_reports() == []
|
| 256 |
-
# Already dismissed — dismissing again finds nothing to update.
|
| 257 |
-
assert await database.dismiss_issue_report(created["id"]) is False
|
| 258 |
-
|
| 259 |
-
|
| 260 |
@pytest.mark.unit
|
| 261 |
def test_score_student_grades_only_concrete_keys():
|
| 262 |
grid = grid_from_dict(
|
|
|
|
| 135 |
assert len(await database.admin_list_sessions()) == 2 # admin sees both
|
| 136 |
|
| 137 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
@pytest.mark.unit
|
| 139 |
def test_score_student_grades_only_concrete_keys():
|
| 140 |
grid = grid_from_dict(
|