Both student-facing surfaces (the per-device join page and the
front-of-room projector) now render options as text only, no A/B/C/D
chips, no numeric prefixes. The letter-namespace remains internal:
canonical A..D in pool.json + submissions storage; canonical position
1..4 in the CSV export. Admin dashboard keeps letters because it is the
instructor's private console.
Why this lands now: the discussion of per-student option shuffling
flagged that A/B/C/D as discussion handles ("the answer is B") is itself
a leaky channel. Removing the labels closes that channel for the
non-shuffled case and keeps it closed if shuffling is added later.
Wire protocol: the submit message carries the option's full text
("answer": "Pipelining"). Server's submit_answer resolves text -> letter
via app.pool.resolve_option_key, which also accepts a canonical letter
so internal callers and tests stay readable. A non-matching string is
recorded as a zero-score submission with answer=NULL, locked in via the
PK + existing_submit_ack short-circuit. So an attempted UI bypass that
posts a fabricated string just produces a wrong answer; no retry.
CSV: A=1 .. D=2 .. C=3 .. D=4 in the answer column. Empty when no option
matched. Header unchanged so downstream pandas readers don't break, but
the value type is int instead of letter.
Histogram: the failsafe "submitted-but-no-match" row buckets into
"missed" alongside genuine misses — both yield zero credit and the
instructor cares about the same thing.
Tests:
- test_submit_accepts_option_text_resolves_to_canonical: production
wire format produces correct grading + canonical-letter storage.
- test_submit_failsafe_locks_in_zero_score_on_garbage_text: a non-
matching string is recorded at score=0 and a follow-up correct
submission cannot overwrite it.
71/71 green.
77 lines
2.5 KiB
Python
77 lines
2.5 KiB
Python
"""CSV export helpers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
from io import StringIO
|
|
|
|
from app.db import connect
|
|
from app.pool import CANONICAL_POSITION
|
|
|
|
|
|
async def export_session_csv(db_path: str, sid: str) -> str:
|
|
out = StringIO()
|
|
writer = csv.writer(out)
|
|
writer.writerow(
|
|
[
|
|
"sid",
|
|
"student_id",
|
|
"name",
|
|
"question_idx",
|
|
# Canonical 1-indexed position of the chosen option in the
|
|
# pool's option list (A=1, B=2, C=3, D=4). Empty when the
|
|
# student didn't submit anything that matched an option.
|
|
"answer",
|
|
"elapsed_ms",
|
|
"score",
|
|
"status",
|
|
"blur_count",
|
|
"hidden_count",
|
|
"duplicate_join_attempts",
|
|
]
|
|
)
|
|
async with connect(db_path) as db:
|
|
cursor = await db.execute(
|
|
"""
|
|
SELECT p.sid, p.student_id, p.name, s.question_idx, s.answer, s.elapsed_ms, s.score, s.status
|
|
FROM participants p
|
|
LEFT JOIN submissions s ON s.sid = p.sid AND s.student_id = p.student_id
|
|
WHERE p.sid = ?
|
|
ORDER BY p.student_id, s.question_idx
|
|
""",
|
|
(sid,),
|
|
)
|
|
rows = await cursor.fetchall()
|
|
events_cur = await db.execute(
|
|
"""
|
|
SELECT student_id, kind, COUNT(*) AS c
|
|
FROM student_events
|
|
WHERE sid = ? AND student_id IS NOT NULL
|
|
GROUP BY student_id, kind
|
|
""",
|
|
(sid,),
|
|
)
|
|
events = await events_cur.fetchall()
|
|
counts: dict[str, dict[str, int]] = {}
|
|
for row in events:
|
|
counts.setdefault(row["student_id"], {})[row["kind"]] = int(row["c"])
|
|
for row in rows:
|
|
per = counts.get(row["student_id"], {})
|
|
answer_pos = CANONICAL_POSITION.get(row["answer"]) if row["answer"] else None
|
|
writer.writerow(
|
|
[
|
|
row["sid"],
|
|
row["student_id"],
|
|
row["name"],
|
|
"" if row["question_idx"] is None else row["question_idx"],
|
|
"" if answer_pos is None else answer_pos,
|
|
"" if row["elapsed_ms"] is None else row["elapsed_ms"],
|
|
"" if row["score"] is None else row["score"],
|
|
row["status"] or "",
|
|
per.get("blur", 0),
|
|
per.get("visibility_hidden", 0),
|
|
per.get("duplicate_join", 0),
|
|
]
|
|
)
|
|
return out.getvalue()
|