taboola commited on
Commit
7d40f39
·
1 Parent(s): 38a90da

Revert "add teacher retention, feedback-loop, and issue-report analytics to admin backend"

Browse files

This reverts commit c5b36039af43f2a5fead8efe0ba08ca4b3b49db3.

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
- 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
 
 
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(