pdf-quiz-generator/backend/scripts/number_board_reviews.py
Daniel 6bb5767871 feat: session analysis after a quiz, and a merged TODO for the rest
The results page showed a score and a wall of explanations. What a learner needs
afterwards is where the time went and what to go back to, so
/analysis/session/:attemptId gives them: a rail of recent sessions, the four
figures they act on — correct, completed, time per question, total time — a
donut, the weakest topics, and a paginated table of every question with its
status, difficulty, time and how peers did on it.

Time per question was not recorded at all, so it could not be reported. It is
now (`attempt_answers.seconds_spent`), banked when you leave a question and
including the one still open at submission — without that the last question of
every session would show nothing. Answers from before this read "—" rather than
claiming zero, and a question nobody else has answered has no peer rate rather
than 0%, which would read as everyone having failed it.

Also in this pass, from the review:
  * quiz categories are gone from the library — a second taxonomy beside the
    real one, putting a heading above every test;
  * the board review sets are numbered rather than dated, in both the quizzes
    and the study plans built from the same material, so a learner does not meet
    2019 in one place and VII in another;
  * the footer's standing note is one clause, and the gap above it no longer
    looks like the page ended early.

Everything else asked for today is written down in docs/TODO.md rather than
half-built: resume instead of restart, an unsuspended exam that keeps running,
deleting a session's data, reset-all-data with a warning, recommendations split
by article/discipline/system, and the adaptive session. Two questions I owe
answers to are in there too.

208 backend, 249 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 03:07:05 +02:00

77 lines
2.8 KiB
Python

"""Number the board review sets instead of dating them.
A year in the title says when the questions were published, which is not
something a learner chooses a set by, and dates material that is otherwise
timeless. Numbering them in order keeps the sequence without the implication.
Both the quizzes and the study plans built from the same material are renamed,
so a learner does not meet "Board Review 2019" in one place and "Board Review
VIII" in another.
docker compose exec backend python -m scripts.number_board_reviews
docker compose exec backend python -m scripts.number_board_reviews --apply
"""
import re
import sys
from sqlalchemy import text as sa_text
from app.database import SessionLocal
NUMERALS = [
(1000, "M"), (900, "CM"), (500, "D"), (400, "CD"), (100, "C"), (90, "XC"),
(50, "L"), (40, "XL"), (10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I"),
]
def roman(number: int) -> str:
out = []
for value, symbol in NUMERALS:
while number >= value:
out.append(symbol)
number -= value
return "".join(out)
def main():
apply_changes = "--apply" in sys.argv
db = SessionLocal()
try:
# Oldest set becomes I, so the numbering follows the order they were sat.
years = sorted({int(m.group(1)) for (title,) in db.execute(sa_text(
"SELECT title FROM quizzes WHERE title ~ 'Board Review [0-9]{4}'")).fetchall()
if (m := re.search(r"(\d{4})", title))})
if not years:
print(" Nothing to renumber.")
return 0
numbering = {year: roman(index) for index, year in enumerate(years, start=1)}
print(f" sets found: {len(years)}\n")
for year, numeral in numbering.items():
print(f" Board Review {year} -> Board Review {numeral}")
if not apply_changes:
print("\n Re-run with --apply.")
return 0
quizzes = renamed_plans = 0
for year, numeral in numbering.items():
quizzes += db.execute(sa_text("""
UPDATE quizzes SET title = replace(title, :old, :new)
WHERE title LIKE :like
"""), {"old": f"Board Review {year}", "new": f"Board Review {numeral}",
"like": f"%Board Review {year}%"}).rowcount or 0
renamed_plans += db.execute(sa_text("""
UPDATE study_plans SET name = :new, slug = :slug WHERE name = :old
"""), {"old": f"Board Review {year}", "new": f"Board Review {numeral}",
"slug": f"board-review-{numeral.lower()}"}).rowcount or 0
db.commit()
print(f"\n quizzes renamed : {quizzes}")
print(f" study plans renamed : {renamed_plans}")
finally:
db.close()
return 0
if __name__ == "__main__":
sys.exit(main())