pdf-quiz-generator/backend/app/services/question_figures.py
Daniel 094bab20cd
Some checks failed
Tests / backend (push) Failing after 9s
Tests / frontend (push) Successful in 38s
Tests / e2e (push) Failing after 43s
feat: an opened explanation figure shows what the library knows
Two voices, and they belong in different places. The thumbnail in an
explanation carries what the *question* says about the figure — words an
educator chose for this question. Opening it now adds the catalogue
entry underneath: what the image was filed as, the description written
when it was filed, where it came from, and any marks an educator drew on
it, behind a switch.

It rides on explanation rows only. Those are already withheld by the
server until answers are revealed, so the record reaches somebody who
has finished the question and gets nowhere near somebody who has not —
which is the whole reason the caption came off the figure in the first
place.

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

91 lines
4 KiB
Python

"""The figures attached to a question.
A question used to carry one stem image and one explanation image as two
filename columns. `question_media` replaced that with rows — any number of
figures, each with a role, a label the prose can refer to, a caption and an
order — but only the editor's own endpoint ever read them, so the quiz player
and the answer review still showed the two legacy paths.
This is the one place that turns those rows into what a page renders, so the
editor, the player and the review cannot disagree about what is on a question.
"""
from collections import defaultdict
from sqlalchemy.orm import Session
from app.models.media import MediaAsset
from app.models.question_media import QuestionMedia
def figure_json(link: QuestionMedia, asset: MediaAsset) -> dict:
return {
"id": link.id,
"media_id": link.media_id,
"role": link.role,
# An educator's label, or nothing. "Figure 1" and "Figure from question
# #3360" told a learner only that an image was an image, and the second
# one told them the internal path it came from as well.
"label": link.label or None,
# What this *question* says about the image, and nothing else.
#
# It used to fall back to the library's own description, which is
# written to catalogue an image, not to sit beside a question — so a
# radiograph attached to a stem about a limping child carried
# "abnormalities in the right hip joint" underneath it, and the
# question was answered before it was read. Every one of the 343
# figures in the bank was inheriting that description; not one had a
# caption of its own. Most question figures want no caption at all,
# and the ones that do want words chosen for the question they are on.
#
# The library description is still the library's, and still shown
# there and in an article. It is not shown in a quiz.
"caption": link.caption or None,
"path": getattr(asset, "path", None),
"position": link.position,
# What the library knows, for an explanation figure only.
#
# Once the answer is in, the catalogue entry is worth having: the
# title, the description written when the image was filed, where it
# came from, and any marks an educator drew on it. Before the answer
# it is a giveaway, which is why the caption fell out of the figure
# itself — so it rides only on the rows that are already withheld
# until answers are revealed. Nothing here is sent with a stem.
"library": _library_json(asset) if link.role == "explanation" else None,
}
def _library_json(asset: MediaAsset) -> dict | None:
if asset is None:
return None
return {
"title": asset.title or None,
"caption": asset.caption or None,
"alt_text": asset.alt_text or None,
"source": asset.source or None,
"source_url": asset.source_url or None,
"overlay": asset.overlay or None,
}
def figures_for_questions(db: Session, question_ids) -> dict[int, list[dict]]:
"""Figures for many questions at once, keyed by question id.
One query for a whole quiz rather than one per question — a 240-question
exam would otherwise fan out into 240 round trips to build one page.
"""
ids = [qid for qid in dict.fromkeys(question_ids) if qid]
if not ids:
return {}
rows = (db.query(QuestionMedia, MediaAsset)
.join(MediaAsset, MediaAsset.id == QuestionMedia.media_id)
.filter(QuestionMedia.question_id.in_(ids))
.order_by(QuestionMedia.role, QuestionMedia.position, QuestionMedia.id)
.all())
out: dict[int, list[dict]] = defaultdict(list)
for link, asset in rows:
out[link.question_id].append(figure_json(link, asset))
return out
def figures_for(db: Session, question_id: int) -> list[dict]:
return figures_for_questions(db, [question_id]).get(question_id, [])