Admin: quiz-detail view (raw files + structured Q&A) + student raw answers

#2
by kuanz - opened
chatkit/backend/app/admin.py CHANGED
@@ -14,6 +14,9 @@ from .database import (
14
  admin_list_students,
15
  delete_session,
16
  delete_user,
 
 
 
17
  get_schema_version,
18
  get_session,
19
  get_stats,
@@ -22,6 +25,7 @@ from .database import (
22
  list_all_users,
23
  set_user_admin,
24
  )
 
25
 
26
  router = APIRouter(prefix="/api/admin", tags=["admin"])
27
 
@@ -63,6 +67,32 @@ 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):
 
14
  admin_list_students,
15
  delete_session,
16
  delete_user,
17
+ get_parsed_data,
18
+ get_quiz_results,
19
+ get_raw_files,
20
  get_schema_version,
21
  get_session,
22
  get_stats,
 
25
  list_all_users,
26
  set_user_admin,
27
  )
28
+ from .storage import create_signed_url
29
 
30
  router = APIRouter(prefix="/api/admin", tags=["admin"])
31
 
 
67
  return {"sessions": await admin_list_sessions()}
68
 
69
 
70
+ @router.get("/sessions/{session_id}")
71
+ async def admin_session_detail(session_id: int, _admin=Depends(get_current_admin)):
72
+ """Full quiz detail: raw files (signed URLs), structured parsed data, results."""
73
+ quiz = await get_session(session_id)
74
+ if not quiz:
75
+ raise HTTPException(status_code=404, detail="Session not found")
76
+
77
+ # Group structured parsed data by type (questions / student_answers / etc.)
78
+ parsed = {}
79
+ for item in await get_parsed_data(session_id):
80
+ parsed[item["data_type"]] = item["structured_data"]
81
+
82
+ # Raw files with short-lived signed download URLs (None if Storage disabled/missing).
83
+ raw_files = []
84
+ for f in await get_raw_files(session_id):
85
+ f["download_url"] = await create_signed_url(f.get("storage_path", ""))
86
+ raw_files.append(f)
87
+
88
+ return {
89
+ "quiz": quiz,
90
+ "raw_files": raw_files,
91
+ "parsed_data": parsed,
92
+ "results": await get_quiz_results(session_id),
93
+ }
94
+
95
+
96
  @router.delete("/sessions/{session_id}")
97
  async def admin_delete_session(session_id: int, _admin=Depends(get_current_admin)):
98
  if not await get_session(session_id):
chatkit/backend/app/database.py CHANGED
@@ -561,6 +561,24 @@ async def admin_list_students(limit: int = 500) -> list[dict]:
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
 
561
  return out
562
 
563
 
564
+ async def get_quiz_results(quiz_id: int) -> list[dict]:
565
+ """All student results for a quiz, joined with student name (admin detail)."""
566
+ async with get_sessionmaker()() as db:
567
+ res = await db.execute(
568
+ select(StudentResult, Student.name, Student.id)
569
+ .join(Student, StudentResult.student_id == Student.id)
570
+ .where(StudentResult.quiz_id == quiz_id)
571
+ .order_by(Student.name)
572
+ )
573
+ out = []
574
+ for sr, name, sid in res.all():
575
+ d = _row(sr)
576
+ d["student_name"] = name
577
+ d["student_id"] = sid
578
+ out.append(d)
579
+ return out
580
+
581
+
582
  async def get_schema_version() -> Optional[str]:
583
  """Current Alembic migration revision applied to the DB."""
584
  from sqlalchemy import text
chatkit/backend/app/storage.py CHANGED
@@ -62,3 +62,23 @@ async def upload_raw_file(
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)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)
65
+
66
+
67
+ def _signed_url_sync(path: str, expires_in: int) -> Optional[str]:
68
+ s = get_settings()
69
+ client = _client()
70
+ res = client.storage.from_(s.supabase_storage_bucket).create_signed_url(path, expires_in)
71
+ # supabase-py has used both 'signedURL' and 'signedUrl' across versions
72
+ if isinstance(res, dict):
73
+ return res.get("signedURL") or res.get("signedUrl") or res.get("signed_url")
74
+ return None
75
+
76
+
77
+ async def create_signed_url(storage_path: str, expires_in: int = 3600) -> Optional[str]:
78
+ """Return a short-lived download URL for a stored object, or None if unavailable."""
79
+ if not storage_path or not storage_enabled():
80
+ return None
81
+ try:
82
+ return await run_in_threadpool(_signed_url_sync, storage_path, expires_in)
83
+ except Exception:
84
+ return None
chatkit/frontend/src/components/admin/AdminDashboard.tsx CHANGED
@@ -43,12 +43,51 @@ interface StudentRow {
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 }[] = [
@@ -187,6 +226,11 @@ function Teachers() {
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
 
@@ -207,9 +251,14 @@ function Sessions() {
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
  ))}
@@ -221,12 +270,11 @@ 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={["姓名", "教師", "測驗次數", "平均分數", ""]}>
@@ -252,7 +300,12 @@ function StudentDetail({ studentId, onBack }: { studentId: number; onBack: () =>
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
 
@@ -267,25 +320,224 @@ function StudentDetail({ studentId, onBack }: { studentId: number; onBack: () =>
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">
 
43
  }
44
 
45
  interface TimelineEntry {
46
+ quiz_id: number;
47
  quiz_title: string;
48
  quiz_subject: string | null;
49
  quiz_created_at: string;
50
  total_questions: number;
51
  correct_count: number;
52
  score: number;
53
+ answers: { answers?: (string | null)[] };
54
+ }
55
+
56
+ interface QuestionMeta {
57
+ number: number;
58
+ text: string;
59
+ options: string[];
60
+ }
61
+
62
+ interface RawFile {
63
+ id: number;
64
+ data_type: string;
65
+ file_name: string;
66
+ content_type: string;
67
+ size_bytes: number;
68
+ storage_path: string;
69
+ download_url: string | null;
70
+ }
71
+
72
+ interface QuizResult {
73
+ student_id: number;
74
+ student_name: string;
75
+ total_questions: number;
76
+ correct_count: number;
77
+ score: number;
78
+ answers: { answers?: (string | null)[] };
79
+ }
80
+
81
+ interface QuizDetailData {
82
+ quiz: { id: number; title: string; subject: string | null; status: string; created_at: string };
83
+ raw_files: RawFile[];
84
+ parsed_data: {
85
+ questions?: { questions: QuestionMeta[] };
86
+ student_answers?: { students: { name: string; id?: string; answers: { question_number: number; answer: string }[] }[] };
87
+ teacher_answers?: { answers: { question_number: number; correct_answer: string; explanation?: string | null }[] };
88
+ answer_grid?: { total_questions: number; official_answers: (string | null)[]; students: { name: string; answers: (string | null)[] }[]; questions: QuestionMeta[] };
89
+ };
90
+ results: QuizResult[];
91
  }
92
 
93
  const TABS: { key: Tab; label: string }[] = [
 
226
 
227
  function Sessions() {
228
  const { data, loading, error, reload } = useFetch<{ sessions: SessionRow[] }>("/api/admin/sessions");
229
+ const [detailId, setDetailId] = useState<number | null>(null);
230
+
231
+ if (detailId !== null) {
232
+ return <QuizDetail quizId={detailId} onBack={() => setDetailId(null)} />;
233
+ }
234
  if (loading) return <Spinner />;
235
  if (error || !data) return <ErrorBox msg={error} />;
236
 
 
251
  <Td>{s.report_count}</Td>
252
  <Td>{new Date(s.created_at).toLocaleDateString()}</Td>
253
  <Td>
254
+ <div className="flex gap-3">
255
+ <button onClick={() => setDetailId(s.id)} className="text-sm underline text-[var(--color-primary)]">
256
+ 查看詳情
257
+ </button>
258
+ <button onClick={() => remove(s)} className="text-sm text-red-400 hover:underline">
259
+ 刪除
260
+ </button>
261
+ </div>
262
  </Td>
263
  </tr>
264
  ))}
 
270
  const { data, loading, error } = useFetch<{ students: StudentRow[] }>("/api/admin/students");
271
  const [selected, setSelected] = useState<number | null>(null);
272
 
 
 
 
273
  if (selected !== null) {
274
  return <StudentDetail studentId={selected} onBack={() => setSelected(null)} />;
275
  }
276
+ if (loading) return <Spinner />;
277
+ if (error || !data) return <ErrorBox msg={error} />;
278
 
279
  return (
280
  <Table headers={["姓名", "教師", "測驗次數", "平均分數", ""]}>
 
300
  student: { name: string };
301
  timeline: TimelineEntry[];
302
  }>(`/api/admin/students/${studentId}`);
303
+ const [quizId, setQuizId] = useState<number | null>(null);
304
+ const [openRow, setOpenRow] = useState<number | null>(null);
305
 
306
+ if (quizId !== null) {
307
+ return <QuizDetail quizId={quizId} onBack={() => setQuizId(null)} />;
308
+ }
309
  if (loading) return <Spinner />;
310
  if (error || !data) return <ErrorBox msg={error} />;
311
 
 
320
  {data.timeline.length === 0 ? (
321
  <p className="text-[var(--color-text-muted)]">尚無成績紀錄</p>
322
  ) : (
323
+ <div className="space-y-3">
324
  {data.timeline.map((t, i) => (
325
+ <div key={i} className="card p-4">
326
+ <div className="flex flex-wrap items-center justify-between gap-2">
327
+ <div>
328
+ <span className="font-semibold text-[var(--color-text)]">{t.quiz_title}</span>
329
+ <span className="text-sm text-[var(--color-text-muted)] ml-2">
330
+ {t.quiz_subject || "—"} · {new Date(t.quiz_created_at).toLocaleDateString()}
331
+ </span>
332
+ </div>
333
+ <div className="flex items-center gap-4 text-sm">
334
+ <span>
335
+ {t.correct_count}/{t.total_questions} 答對 ·{" "}
336
+ <span className="font-semibold text-[var(--color-primary)]">{t.score} 分</span>
337
+ </span>
338
+ <button
339
+ onClick={() => setOpenRow(openRow === i ? null : i)}
340
+ className="underline text-[var(--color-primary)]"
341
+ >
342
+ {openRow === i ? "收合作答" : "查看作答"}
343
+ </button>
344
+ <button onClick={() => setQuizId(t.quiz_id)} className="underline text-[var(--color-primary)]">
345
+ 測驗詳情
346
+ </button>
347
+ </div>
348
+ </div>
349
+ {openRow === i && (
350
+ <div className="mt-3 border-t border-[var(--color-border)] pt-3">
351
+ <p className="text-xs text-[var(--color-text-muted)] mb-2">
352
+ 原始作答資料(= 代表答對;字母為作答選項;空白為未作答)
353
+ </p>
354
+ <AnswerChips answers={t.answers?.answers ?? []} />
355
+ </div>
356
+ )}
357
+ </div>
358
  ))}
359
+ </div>
360
  )}
361
  </div>
362
  );
363
  }
364
 
365
+ function QuizDetail({ quizId, onBack }: { quizId: number; onBack: () => void }) {
366
+ const { data, loading, error } = useFetch<QuizDetailData>(`/api/admin/sessions/${quizId}`);
367
+ const [view, setView] = useState<"questions" | "answers" | "raw">("questions");
368
+
369
+ if (loading) return <Spinner />;
370
+ if (error || !data) return <ErrorBox msg={error} />;
371
+
372
+ const questions = data.parsed_data.questions?.questions ?? [];
373
+ const grid = data.parsed_data.answer_grid;
374
+ const teacher = data.parsed_data.teacher_answers?.answers ?? [];
375
+
376
+ const subTabs: { key: typeof view; label: string }[] = [
377
+ { key: "questions", label: `題目結構資料 (${questions.length})` },
378
+ { key: "answers", label: `作答結構資料 (${data.results.length})` },
379
+ { key: "raw", label: `原始檔案 (${data.raw_files.length})` },
380
+ ];
381
+
382
+ return (
383
+ <div>
384
+ <button onClick={onBack} className="text-sm text-[var(--color-text-muted)] hover:underline mb-4">
385
+ ← 返回
386
+ </button>
387
+ <h3 className="font-display text-xl font-semibold mb-1 text-[var(--color-text)]">
388
+ 測驗詳情:{data.quiz.title}
389
+ </h3>
390
+ <p className="text-sm text-[var(--color-text-muted)] mb-4">
391
+ {data.quiz.subject || "—"} · 狀態 {data.quiz.status} ·{" "}
392
+ {new Date(data.quiz.created_at).toLocaleString()}
393
+ </p>
394
+
395
+ <div className="flex gap-2 mb-4">
396
+ {subTabs.map((t) => (
397
+ <button
398
+ key={t.key}
399
+ onClick={() => setView(t.key)}
400
+ className={`px-3 py-1.5 text-sm rounded-lg transition-colors ${
401
+ view === t.key
402
+ ? "bg-[var(--color-primary)] text-white"
403
+ : "text-[var(--color-text-muted)] border border-[var(--color-border)]"
404
+ }`}
405
+ >
406
+ {t.label}
407
+ </button>
408
+ ))}
409
+ </div>
410
+
411
+ {view === "questions" && (
412
+ questions.length === 0 ? (
413
+ <Empty msg="尚無題目結構資料" />
414
+ ) : (
415
+ <div className="space-y-2">
416
+ {questions.map((q, i) => {
417
+ // Prefer the number-keyed teacher answer (gap-tolerant); fall back to the
418
+ // positional grid cell. '=' means "no authoritative key" → don't show it.
419
+ const rawOfficial =
420
+ teacher.find((a) => a.question_number === q.number)?.correct_answer ??
421
+ grid?.official_answers?.[q.number - 1] ??
422
+ null;
423
+ const official = rawOfficial === "=" ? null : rawOfficial;
424
+ return (
425
+ <div key={`${q.number}-${i}`} className="card p-3">
426
+ <div className="flex items-start gap-2">
427
+ <span className="font-semibold text-[var(--color-primary)] shrink-0">Q{q.number}</span>
428
+ <div className="min-w-0">
429
+ <p className="text-sm text-[var(--color-text)] whitespace-pre-wrap break-words">{q.text || "(無題目文字)"}</p>
430
+ {q.options?.length > 0 && (
431
+ <ul className="mt-1 text-sm text-[var(--color-text-muted)] list-disc ml-5">
432
+ {q.options.map((o, j) => <li key={j}>{o}</li>)}
433
+ </ul>
434
+ )}
435
+ {official && (
436
+ <p className="mt-1 text-sm">
437
+ 正解:<span className="font-semibold text-[var(--color-success)]">{official}</span>
438
+ </p>
439
+ )}
440
+ </div>
441
+ </div>
442
+ </div>
443
+ );
444
+ })}
445
+ </div>
446
+ )
447
+ )}
448
+
449
+ {view === "answers" && (
450
+ <div className="space-y-4">
451
+ {grid?.official_answers && (
452
+ <div className="card p-3">
453
+ <p className="text-xs text-[var(--color-text-muted)] mb-2">標準答案</p>
454
+ <AnswerChips answers={grid.official_answers} hideEquals />
455
+ </div>
456
+ )}
457
+ {data.results.length === 0 ? (
458
+ <Empty msg="尚無學生作答結構資料" />
459
+ ) : (
460
+ data.results.map((r) => (
461
+ <div key={r.student_id} className="card p-3">
462
+ <div className="flex items-center justify-between mb-2">
463
+ <span className="font-semibold text-[var(--color-text)]">{r.student_name}</span>
464
+ <span className="text-sm">
465
+ {r.correct_count}/{r.total_questions} ·{" "}
466
+ <span className="font-semibold text-[var(--color-primary)]">{r.score} 分</span>
467
+ </span>
468
+ </div>
469
+ <AnswerChips answers={r.answers?.answers ?? []} />
470
+ </div>
471
+ ))
472
+ )}
473
+ </div>
474
+ )}
475
+
476
+ {view === "raw" && (
477
+ data.raw_files.length === 0 ? (
478
+ <Empty msg="此測驗沒有原始檔案(PDF/CSV)。較早上傳的資料未保存原始檔;之後上傳的會自動保存到 Supabase Storage。" />
479
+ ) : (
480
+ <Table headers={["類型", "檔名", "格式", "大小", "下載"]}>
481
+ {data.raw_files.map((f) => (
482
+ <tr key={f.id} className="border-t border-[var(--color-border)]">
483
+ <Td>{f.data_type}</Td>
484
+ <Td>{f.file_name}</Td>
485
+ <Td>{f.content_type || "—"}</Td>
486
+ <Td>{f.size_bytes ? `${(f.size_bytes / 1024).toFixed(1)} KB` : "—"}</Td>
487
+ <Td>
488
+ {f.download_url ? (
489
+ <a href={f.download_url} target="_blank" rel="noreferrer" className="underline text-[var(--color-primary)]">
490
+ 下載 / 檢視
491
+ </a>
492
+ ) : (
493
+ <span className="text-[var(--color-text-muted)]">不可用</span>
494
+ )}
495
+ </Td>
496
+ </tr>
497
+ ))}
498
+ </Table>
499
+ )
500
+ )}
501
+ </div>
502
+ );
503
+ }
504
+
505
+ function AnswerChips({ answers, hideEquals }: { answers: (string | null)[]; hideEquals?: boolean }) {
506
+ return (
507
+ <div className="flex flex-wrap gap-1.5">
508
+ {answers.map((a, i) => {
509
+ const isEq = a === "=";
510
+ const blank = a === null || a === "";
511
+ // '=' means "answered correctly" in a student row (show ✓), but "no
512
+ // authoritative key" in the official-answer row (hideEquals → neutral).
513
+ const showCorrect = isEq && !hideEquals;
514
+ const neutral = blank || (isEq && hideEquals);
515
+ const label = neutral ? "·" : showCorrect ? "✓" : a;
516
+ return (
517
+ <span
518
+ key={i}
519
+ title={`Q${i + 1}`}
520
+ className={`inline-flex items-center justify-center min-w-[2.2rem] px-1.5 py-0.5 rounded text-xs border ${
521
+ showCorrect
522
+ ? "bg-[var(--color-success)]/10 border-[var(--color-success)]/30 text-[var(--color-success)]"
523
+ : neutral
524
+ ? "border-[var(--color-border)] text-[var(--color-text-muted)]"
525
+ : "border-[var(--color-border)] text-[var(--color-text)]"
526
+ }`}
527
+ >
528
+ <span className="opacity-50 mr-0.5">{i + 1}</span>
529
+ {label}
530
+ </span>
531
+ );
532
+ })}
533
+ </div>
534
+ );
535
+ }
536
+
537
+ function Empty({ msg }: { msg: string }) {
538
+ return <div className="card p-6 text-center text-[var(--color-text-muted)] text-sm">{msg}</div>;
539
+ }
540
+
541
  function Table({ headers, children }: { headers: string[]; children: React.ReactNode }) {
542
  return (
543
  <div className="card overflow-x-auto">