Skip to content

End-of-term attendance export, and stop question deletion from breaking reports - #14

Open
percolator wants to merge 6 commits into
mainfrom
claude/bioinformatics-quiz-app-lpr8hz
Open

End-of-term attendance export, and stop question deletion from breaking reports#14
percolator wants to merge 6 commits into
mainfrom
claude/bioinformatics-quiz-app-lpr8hz

Conversation

@percolator

Copy link
Copy Markdown
Contributor

Two changes, both about answers surviving long enough to report on.

An end-of-term participation export

GET /api/reports/participation.csv?from=&to= — one CSV for a whole semester, reachable from the dashboard under End-of-term participation with an optional date range.

One row per student, one column per session, yes/no per cell. Correctness is deliberately absent: this is the participation-credit record, not a mark.

username,name,2026-01-14 Alignment (kq8fzm),2026-01-21 Assembly (p4wtrn),sessions_attended,sessions_total
adam,Adam A,yes,yes,2,2
bo,Bo B,yes,no (1/2),1,2

What "took part" means. The student answered both the pre and the post round of every question that was asked in both bouts. Two deliberate softenings:

  • A question that never got its second bout counts against nobody, and a session where no question ran both bouts is blank rather than an absence — the teacher not running a post round is not the student's absence.
  • A failing cell carries the fraction (no (1/2)), because a strict all-or-nothing verdict would otherwise make a student who answered three of four questions indistinguishable from one who never turned up.

If you'd rather have a threshold ("at least 75% of pairs") than all-or-nothing, the rule lives in one place — service.semester_participation — and the fraction is already in the JSON.

Personal data, so teacher-only, restricted to the requester's own sessions, Cache-Control: no-store, and labelled do-not-project like the Participants view.

Deleting a question silently broke reporting

Found while answering how long answers survive. Probing a real deletion against a session with a recorded answer:

delete question:              204
comparison after delete:      404
participation after delete:   AttributeError: 'NoneType' object has no attribute 'choices'
participation.csv:            AttributeError: 'NoneType' object has no attribute 'choices'
orphaned rounds: 1    surviving answers: 1

Nothing cascades from a question to the rounds that asked it. Deleting a used question did not remove its answers — it stranded them: the rows stayed in the database, Round.question became None, and participation_report dereferenced it, so /participation and /participation.csv raised for every session that had used that question. The data was intact; the only way to read it was gone. The trigger looks like tidying up a quiz.

Now refused with 409 pointing at the reset button, for when discarding answers really is the intent. The report also skips a round whose question is missing, so sessions already damaged by the old path become reportable again.

Tests

90 backend, 5 frontend unit, 9 browser specs.

  • test_semester_report.py — both-bouts-not-correctness, every asked pair required, single-bout questions not held against anyone, date filtering, CSV shape, partial attendance visible, teacher-only, not public.
  • test_answers_survive.py — deletion refused, unused question still deletable, report survives data damaged by the old build.
  • semester-report.spec.mjs — drives a real lecture in a browser with two students (one answers both bouts, one only the first), downloads the CSV from the dashboard and checks what it says.

Not addressed

The database is still a single SQLite file with no backup. The app now protects answers from itself, not from the volume — exporting after a lecture remains the only real safeguard.


Generated by Claude Code

claude added 6 commits August 3, 2026 19:30
A redeploy left browsers reporting

  Failed to load module script: Expected a JavaScript-or-Wasm module script
  but the server responded with a MIME type of "text/html"

for chunk-H3QWKNOY.js and chunk-UNPCGV2O.js. Angular fingerprints its
bundles and index.html names the exact set belonging to one build, so a
cached index.html outlives the deploy that produced it and asks for chunks
the new build does not contain. The SPA fallback answered those with
index.html, turning a missing file into a MIME-type error that says nothing
about the cause, and letting the browser cache HTML under a script URL.

Two rules: a path whose last segment has an extension is an artefact
request and 404s when absent rather than falling through, and index.html is
served no-cache so it is revalidated on every load. Fingerprinted bundles
are immutable by construction and now say so.

The e2e specs were confirmed to fail against the previous behaviour with
the reported symptom before the fix was applied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018u3wNzXNdrPw2Z59WjGHYv
SignatureExpired subclasses BadSignature, so catching only the latter
reported every routine 12-hour expiry as "Invalid session" — the message
that means the server's session secret is not what signed the cookie. That
sent us looking for a session-secret bug when the cookie had simply aged
out.

Expiry now reports "Session expired". "Invalid session" keeps its narrower
and much more interesting meaning: a well-formed, in-date cookie this
server cannot verify.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018u3wNzXNdrPw2Z59WjGHYv
The window was 12 hours from login, so a teacher who prepared the evening
before was signed out by lecture time. It is now a week, and measured from
last use rather than from login: a request made with a cookie older than a
day re-issues it, so a session in use cannot lapse mid-lecture, while one
genuinely abandoned still ages out.

Renewal is a middleware rather than part of the current_user dependency.
FastAPI merges a dependency's response headers only when the endpoint
returns data to serialise; an endpoint returning a Response directly gets
its own headers untouched (routing.py: `response = raw_response`). Setting
the cookie in the dependency therefore works for every JSON endpoint and
silently does nothing for qr.svg and the SPA fallback — verified against a
minimal app, and pinned by a test that renews through qr.svg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018u3wNzXNdrPw2Z59WjGHYv
The secret every request verifies the session cookie against was a plain
@Property, so it was recomputed on each request, and its create path checked
whether the file existed and then wrote it. Both are wrong:

  - Concurrent callers with no file yet each generated their own and
    overwrote the last. A test driving twelve at once produced six different
    secrets from a single cold start.
  - A plain write truncates, so a concurrent reader saw an empty file and
    generated yet another.
  - An unreadable file (one left by an image that ran as a different uid)
    fell through `except OSError: pass` to a per-process random value,
    silently.

The symptom is a cookie accepted by one request and rejected by the next,
reported only as 401 — POST /api/sessions succeeding and every follow-up GET
for that session failing.

The file is now created with O_EXCL, so exactly one caller wins and the rest
adopt its value, and the result is cached per process. The last-resort random
secret is logged as an error rather than used in silence.

/api/health additionally reports the instance id and a truncated hash of the
secret, so two processes that disagree can be told apart from outside the
container; the same line is logged at startup. The hash is 32 bits of a
384-bit token — enough to compare, useless for forging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018u3wNzXNdrPw2Z59WjGHYv
Nothing cascades from a question to the rounds that asked it, so deleting a
question that had already been used did not remove its answers — it stranded
them. The rows stayed in the database while Round.question became None, and
participation_report dereferenced it, so /participation and
/participation.csv raised for *every* session that had used the question.
The report needed for later marking was the thing that broke, silently, from
an action that looks like tidying up a quiz.

Deleting a used question is now refused with 409 and a pointer to the reset
button for when discarding answers is actually the intent. The report also
skips a round whose question is missing, so data already damaged by the old
path can still be reported on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018u3wNzXNdrPw2Z59WjGHYv
One CSV for a whole semester: a row per student, a column per session, and
yes/no for whether they took part in both bouts. Correctness is deliberately
absent — this is the participation-credit record, not a mark.

"Took part" means the student answered both the pre and the post round of
every question that was asked in both bouts. A question that never got its
second bout counts against nobody, and a session where no question ran twice
has nothing to attend, so it is left blank rather than scored as an absence.
A failing cell carries the fraction ("no (1/2)") because a strict
all-or-nothing verdict would otherwise hide a student who answered three
questions out of four.

Reachable from the dashboard with an optional date range, so one term can be
exported without the previous one. Personal data: teacher-only, own sessions
only, and labelled do-not-project like the Participants view.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018u3wNzXNdrPw2Z59WjGHYv
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants