feat: the exam player as the reference has it

The incomplete-block warning is the one from the screenshot: a red
heading that says the block is incomplete, the count of unanswered items,
the sentence about resuming not matching exam day, and End Block against
Remain in Block. My version asked the question in my own words and led
with the wrong button.

Pausing says "Exam Paused" and offers Return to exam. Nothing else — the
warning about real exams is somebody else's disclaimer, not ours.

Exit session asks "Are you sure you want to end this session?" before it
goes, rather than going.

Time's Up says what it is and the button says Close, which is the only
thing left to do: it is already handed in and marked, and Close lands on
the session's analysis.

One name for one action: the bottom button read Skip on an unanswered
question and Next on an answered one, while the arrow an inch above it
said Next for both.

And the rail shows stems again once the block is handed in. Numbers while
it is being sat — reading ahead is not something the exam being rehearsed
allows — but there is nothing left to protect afterwards, so the review
reads like study mode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-12 05:43:25 +02:00
parent 1959534754
commit 7ba37561e4
9 changed files with 194 additions and 129 deletions

View file

@ -53,7 +53,13 @@ class Settings(BaseSettings):
S3_REGION: str = "us-east-1"
MAX_UPLOAD_SIZE: int = 524288000 # 500MB
TURNSTILE_SECRET_KEY: str = "" # Cloudflare Turnstile — leave blank to disable captcha
# hCaptcha. Leave the secret blank to disable the challenge entirely.
HCAPTCHA_SECRET_KEY: str = ""
# The browser gets its own copy of the site key from the frontend
# container, which is a separate image with a separate .env. This one is
# here so an operator can keep both halves of the pair together and see
# at a glance which widget the secret belongs to.
HCAPTCHA_SITE_KEY: str = ""
ADMIN_EMAIL: str = "" # Where contact form submissions are emailed
DEFAULT_ADMIN_EMAIL: str = "" # Optional explicit bootstrap admin email

View file

@ -4,7 +4,7 @@ from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status, Request
from sqlalchemy.orm import Session
from app.services import invites, site_settings
from app.services import captcha, invites, site_settings
from app.database import get_db
from app.models.user import User
from app.models.email_verification import EmailVerification
@ -61,25 +61,6 @@ def _check_reset_rate_limit(db: Session, email: str):
)
async def _verify_turnstile(token: str) -> bool:
"""Verify Cloudflare Turnstile token. Returns True if valid or if not configured."""
from app.config import settings as cfg
secret = cfg.TURNSTILE_SECRET_KEY
if not secret:
return True
try:
import httpx
resp = httpx.post(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
data={"secret": secret, "response": token},
timeout=10,
)
return resp.json().get("success", False)
except Exception as e:
import logging; logging.getLogger(__name__).warning(f"Turnstile verification failed (failing open): {e}")
return True
@router.get("/signup-policy")
def signup_policy(db: Session = Depends(get_db)):
"""What a would-be member needs, before they are anybody.
@ -98,13 +79,9 @@ def signup_policy(db: Session = Depends(get_db)):
@router.post("/register")
async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
# Verify Turnstile if configured
from app.config import settings as cfg
if cfg.TURNSTILE_SECRET_KEY:
if not user_data.turnstile_token:
raise HTTPException(status_code=400, detail="Bot verification required")
if not await _verify_turnstile(user_data.turnstile_token):
raise HTTPException(status_code=400, detail="Bot verification failed — please try again")
# Failing open: an hCaptcha outage should cost the site a little spam, not
# every account that would have been created while it lasted.
await captcha.require_human(user_data.captcha_token, fail_open=True)
# Check if registration is enabled (unless this is the first user - always allow admin creation)
is_first_user = db.query(User).count() == 0

View file

@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
from app.database import get_db
from app.config import settings
from app.services import captcha
router = APIRouter()
log = logging.getLogger(__name__)
@ -20,25 +21,7 @@ class ContactRequest(BaseModel):
email: EmailStr
type: str # "question" | "moderator"
message: str
turnstile_token: str | None = None
async def _verify_turnstile(token: str) -> bool:
"""Verify Cloudflare Turnstile token. Returns True if valid or if Turnstile not configured."""
secret = getattr(settings, "TURNSTILE_SECRET_KEY", None)
if not secret:
return True # Not configured — allow in dev/no-captcha mode
try:
import httpx
resp = httpx.post(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
data={"secret": secret, "response": token},
timeout=10,
)
return resp.json().get("success", False)
except Exception as e:
log.warning(f"Turnstile verification failed: {e}")
return False
captcha_token: str | None = None
@router.post("")
@ -52,13 +35,10 @@ async def submit_contact(req: ContactRequest, db: Session = Depends(get_db)):
if not name or not message:
raise HTTPException(status_code=400, detail="Name and message are required")
# Verify Turnstile if configured
secret = getattr(settings, "TURNSTILE_SECRET_KEY", None)
if secret:
if not req.turnstile_token:
raise HTTPException(status_code=400, detail="Bot verification required")
if not await _verify_turnstile(req.turnstile_token):
raise HTTPException(status_code=400, detail="Bot verification failed — please try again")
# Failing shut, unlike registration: if hCaptcha cannot be reached, the
# cost of turning this form away is one retry, and the inbox behind it has
# no other defence.
await captcha.require_human(req.captcha_token, fail_open=False)
db.execute(text(
"INSERT INTO contact_submissions (name, email, type, message) VALUES (:name, :email, :type, :message)"

View file

@ -138,7 +138,7 @@ def _quiz_payload(db: Session, quiz: Quiz, include_questions: bool = True):
@router.post("/auth/login")
def mobile_login(data: MobileLoginRequest, request: Request, db: Session = Depends(get_db)):
"""Password login for native apps. Uses rate limiting instead of browser Turnstile."""
"""Password login for native apps. Uses rate limiting instead of a browser captcha."""
client_ip = request.client.host if request and request.client else "unknown"
_mobile_login_rate_limit(client_ip)

View file

@ -7,7 +7,8 @@ class UserCreate(BaseModel):
email: EmailStr
password: str
name: str
turnstile_token: str | None = None
#: Solved hCaptcha challenge. Absent when the site has no secret configured.
captcha_token: str | None = None
#: Required only while the site is invite-only.
invite_code: str | None = None

View file

@ -0,0 +1,66 @@
"""hCaptcha checks on the two forms a stranger can reach.
Both the sign-up and the contact form used to carry their own copy of this,
which is how they drifted apart. It lives here once so that the answer to
"what happens when the challenge is missing, wrong, or unanswerable" is given
in a single place.
A blank secret means no captcha at all. That is the deliberate default: a
deployment that has never signed up for hCaptcha must still be able to
register its first administrator, so an unset key reads as "there is no
challenge here", never as "reject everybody".
"""
import logging
from fastapi import HTTPException
from app.config import settings
logger = logging.getLogger(__name__)
VERIFY_URL = "https://api.hcaptcha.com/siteverify"
def configured() -> bool:
return bool(settings.HCAPTCHA_SECRET_KEY)
async def verify(token: str, *, fail_open: bool) -> bool:
"""Ask hCaptcha whether this token is a real, unspent solve.
`fail_open` decides what an unreachable hCaptcha means, and the two callers
genuinely want different answers: an outage that stops people creating
accounts costs the site its users, while an outage that bounces a contact
message costs the sender one retry.
"""
if not configured():
return True
try:
import httpx
# Awaited rather than blocking: uvicorn runs four workers, and a
# ten-second stall on a third party would otherwise hold up every
# other request that worker is serving.
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
VERIFY_URL,
data={"secret": settings.HCAPTCHA_SECRET_KEY, "response": token},
)
return resp.json().get("success", False)
except Exception:
logger.warning("hCaptcha verification unavailable; %s",
"allowing the request" if fail_open else "rejecting the request",
exc_info=True)
return fail_open
async def require_human(token: str | None, *, fail_open: bool) -> None:
"""Reject the submission unless it carries a solved challenge.
Silent when no secret is set, so an unconfigured site keeps working.
"""
if not configured():
return
if not token:
raise HTTPException(status_code=400, detail="Bot verification required")
if not await verify(token, fail_open=fail_open):
raise HTTPException(status_code=400, detail="Bot verification failed — please try again")

View file

@ -433,6 +433,10 @@ export default function QuizPage() {
const [sessionSeconds, setSessionSeconds] = useState(0)
const [questionSeconds, setQuestionSeconds] = useState(0)
const [clockPaused, setClockPaused] = useState(false)
// Asked before the session closes. Leaving is not destructive the answers
// are saved and it can be resumed but it is not what a mis-aimed thumb
// should do either.
const [leaving, setLeaving] = useState(false)
// The clock ran out: the answers are in, and the analysis waits behind an
// acknowledgement rather than replacing the exam without a word.
const [timeUp, setTimeUp] = useState(false)
@ -1250,12 +1254,12 @@ const timerStarted = timeLeft !== null
{isStudy ? 'Finish session' : 'End block'}
</button>
) : (
/* Skip, when nothing has been chosen. Moving on from a question you
have not answered is a decision, and the button should say which
decision it is rather than calling both of them Next. */
/* One name. It used to read Skip on an unanswered question and Next on
an answered one, while the arrow at the top of the screen said Next
for both two words for one action, an inch apart. */
<button className="btn btn-primary"
onClick={() => safeNavigate(Math.min(totalCount - 1, currentIdx + 1))}>
{answers[current?.id] ? 'Next →' : 'Skip →'}
Next
</button>
)}
</div>
@ -1286,6 +1290,9 @@ const timerStarted = timeLeft !== null
}
}
// Handed in: the block is over, so the rail may say what each question was.
const reviewing = answeredCount >= totalCount && totalCount > 0
const QuestionRailItem = ({ q, i }) => {
const isActive = i === currentIdx
const isDone = !!answers[q.id]
@ -1293,10 +1300,11 @@ const timerStarted = timeLeft !== null
// Only questions the learner has reached show their text. Previewing one
// they have not opened would give away the case before they read it.
//
// In exam mode none of them do. A real paper's status rail is a column of
// numbers, and reading the stems of the questions still to come is not
// something the exam being rehearsed would allow.
const seen = !isStudy ? false : seenIndexes.has(i)
// While an exam is being sat, none of them do: a real paper's status rail
// is a column of numbers, and reading the stems still to come is not
// something the exam being rehearsed would allow. Once it is handed in
// there is nothing left to protect, so the review reads like study mode.
const seen = (isStudy || reviewing) ? seenIndexes.has(i) : false
const excerpt = seen ? questionStem(q).replace(/\s+/g, ' ').trim() : ''
return (
<button type="button"
@ -1328,63 +1336,85 @@ const timerStarted = timeLeft !== null
.map((question, index) => ({ question, index }))
.filter(({ question }) => !answers[question.id])
return (
<QuizDialog title="End block?" onClose={() => setShowReview(false)}>
{/* A confirmation with one button is not a confirmation. The way
back into the block is the first thing offered, and the count
is a sentence rather than a grid to be counted by eye. */}
<p className="quiz-end-lead">
{missing.length === 0
? `All ${totalCount} questions are answered.`
: `${missing.length} of ${totalCount} question${totalCount === 1 ? '' : 's'} `
+ `${missing.length === 1 ? 'is' : 'are'} unanswered.`}
</p>
{missing.length > 0 && (
<>
<p className="quiz-end-note">
They are not marked wrong the score is out of what you
answered but they are not marked at all.
</p>
<div className="quiz-end-missing">
{missing.map(({ question, index }) => (
<button type="button" key={question.id}
onClick={() => { safeNavigate(index); setShowReview(false) }}>
{index + 1}
</button>
))}
</div>
</>
)}
<div className="quiz-end-actions">
<button type="button" className="btn btn-secondary"
onClick={() => setShowReview(false)}>
{missing.length > 0 ? 'Go back and answer them' : 'Keep working'}
</button>
<button type="button" className="btn btn-primary" disabled={submitting}
onClick={() => { setShowReview(false); handleSubmit(false) }}>
{submitting ? 'Ending…' : 'End block'}
</button>
<div className="quiz-away" role="dialog" aria-modal="true"
aria-labelledby="endblock-heading">
<div className={`quiz-away-card quiz-endblock${missing.length ? ' is-warning' : ''}`}>
<h2 id="endblock-heading">
{missing.length
? 'Warning - This block is incomplete!'
: 'End Block'}
</h2>
{missing.length ? (
<>
<p>Number of unanswered items in this block: {missing.length}</p>
{/* Said because it is true, not to talk anybody out of it:
the block can be resumed, and a real paper cannot. */}
<p>
You will be able to resume, however we do not recommend this
as this deviates from your exam day experience.
</p>
</>
) : (
<p>All {totalCount} questions are answered.</p>
)}
<div className="quiz-away-actions">
<button type="button" className="btn quiz-endblock-go" disabled={submitting}
onClick={() => { setShowReview(false); handleSubmit(false) }}>
{submitting ? 'Ending…' : 'End Block'}
</button>
<button type="button" className="btn btn-primary"
onClick={() => setShowReview(false)}>Remain in Block</button>
</div>
</div>
</QuizDialog>
</div>
)
})()}
{submitError && <div role="alert" className="quiz-submit-error">{submitError} <button type="button" disabled={submitting} onClick={() => handleSubmit(false)}>Retry submission</button></div>}
{progressError && <div role="alert" className="quiz-submit-error">{progressError} <button type="button" onClick={() => saveProgressNow()}>Retry saving</button></div>}
{timeUp && (
<div className="quiz-away" role="dialog" aria-modal="true" aria-labelledby="timeup-heading">
<div className="quiz-away-card">
<h2 id="timeup-heading">Time's up</h2>
<p>
You have run out of time for this block. What you answered has
been handed in and marked.
</p>
<h2 id="timeup-heading">Time&apos;s Up</h2>
<p>You have run out of time to complete this question block.</p>
{/* Closing is the only thing left to do: it is already handed in
and marked, and this lands on the session's analysis. */}
<button type="button" className="btn btn-primary" disabled={submitting}
onClick={() => { setTimeUp(false); afterTimeUp?.() }}>
{submitting ? 'Marking…' : 'See how you did'}
{submitting ? 'Marking…' : 'Close'}
</button>
</div>
</div>
)}
{clockPaused && !isStudy && (
<div className="quiz-away" role="dialog" aria-modal="true" aria-labelledby="paused-heading">
<div className="quiz-away-card">
<h2 id="paused-heading">Exam Paused</h2>
{/* Nothing else. The clock is stopped and the questions are
covered; a paragraph about how real exams work is somebody
else's disclaimer. */}
<button type="button" className="btn btn-primary"
onClick={() => setClockPaused(false)}>Return to exam</button>
</div>
</div>
)}
{leaving && (
<div className="quiz-away" role="dialog" aria-modal="true" aria-labelledby="leaving-heading">
<div className="quiz-away-card">
<h2 id="leaving-heading">End Session</h2>
<p>Are you sure you want to end this session?</p>
<div className="quiz-away-actions">
<button type="button" className="btn btn-primary"
onClick={() => { setLeaving(false); leaveNow() }}>End Session</button>
<button type="button" className="btn btn-secondary"
onClick={() => setLeaving(false)}>Cancel</button>
</div>
</div>
</div>
)}
{/* Still there? The clock is already stopped by the time this shows
it is not a threat, it is how the time stays honest. */}
{(askingStillHere || away) && (
@ -1906,7 +1936,7 @@ const timerStarted = timeLeft !== null
scrolls inside it, rather than the whole page scrolling. */}
<div className="quiz-footbar">
<button type="button" className="btn btn-secondary btn-sm quiz-exit"
onClick={() => leaveNow()}>Exit session</button>
onClick={() => setLeaving(true)}>Exit session</button>
{quizNavigation('bottom')}
{/* Reviewing a block before handing it in is an exam idea. A study
session has nothing to hand in: it keeps going until every

View file

@ -241,6 +241,7 @@ describe('quiz player', () => {
// point of this test is what is saved, not what is provisionally picked.
fireEvent.keyDown(window, { key: 'Enter' })
await userEvent.click(screen.getByRole('button', { name: 'Exit session' }))
await userEvent.click(screen.getByRole('button', { name: 'End Session' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Keep this tab open')
expect(inCard().getByText('Full first clinical question.')).toBeInTheDocument()
failSaving = false
@ -255,6 +256,7 @@ describe('quiz player', () => {
// One press. Nothing is lost by leaving the answers are saved and the
// clock pauses so there is nothing to confirm.
await userEvent.click(screen.getByRole('button', { name: 'Exit session' }))
await userEvent.click(screen.getByRole('button', { name: 'End Session' }))
// Leaving a session part-way through should land where what you answered
// is scored and Resume sits not on a list of every session you own.
expect(await screen.findByText('Submitted results')).toBeInTheDocument()
@ -350,7 +352,7 @@ describe('quiz player', () => {
expect(bar.closest('.quiz-layout')).toBeNull()
expect(within(bar).getByRole('button', { name: 'Exit session' })).toBeInTheDocument()
// Nothing answered yet, so moving on is a skip and the button says so.
expect(within(bar).getByRole('button', { name: /Skip/ })).toBeInTheDocument()
expect(within(bar).getByRole('button', { name: /Next/ })).toBeInTheDocument()
})
it('keeps notes with the question, not in a second notepad floating over it', async () => {
@ -461,17 +463,18 @@ describe('quiz player', () => {
expect(screen.queryByText(/Full explanation/)).not.toBeInTheDocument()
expect(api.get.mock.calls.some(([url]) => url.startsWith('/study-tools/attempts/'))).toBe(false)
await userEvent.click(screen.getAllByRole('button', { name: 'Review & Complete' })[0])
const review = screen.getByRole('dialog', { name: 'End block?' })
const review = screen.getByRole('dialog', { name: /This block is incomplete/ })
// The sentence names what is missing, not what is done that is the
// decision being confirmed.
expect(within(review).getByText(/1 of 2 questions is unanswered/)).toBeInTheDocument()
expect(within(review).getByText(/Number of unanswered items in this block: 1/)).toBeInTheDocument()
// And there is a way back into the block, not only a way out of it.
expect(within(review).getByRole('button', { name: 'Go back and answer them' })).toBeInTheDocument()
await userEvent.click(within(review).getByRole('button', { name: '2' }))
expect(within(review).getByRole('button', { name: 'Remain in Block' })).toBeInTheDocument()
await userEvent.click(within(review).getByRole('button', { name: 'Remain in Block' }))
await userEvent.click(screen.getAllByRole('button', { name: /Next/ })[0])
await findStem('Full second clinical question.')
expect(api.post.mock.calls.some(([url]) => url === '/attempts/50/submit')).toBe(false)
await userEvent.click(screen.getAllByRole('button', { name: 'Review & Complete' })[0])
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'End block' }))
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'End Block' }))
expect(await screen.findByText('Submitted results')).toBeInTheDocument()
expect(api.post).toHaveBeenCalledWith('/attempts/50/submit', expect.objectContaining({
answers: [{ question_id: 1, user_answer: 'First answer' }],
@ -486,7 +489,7 @@ describe('quiz player', () => {
expect(screen.getByRole('note')).toHaveTextContent('extrathoracic')
fireEvent.keyDown(window, { key: '1' })
await userEvent.click(screen.getAllByRole('button', { name: 'Review & Complete' })[0])
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'End block' }))
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'End Block' }))
expect(await screen.findByText('Submitted results')).toBeInTheDocument()
expect(api.post).toHaveBeenCalledWith('/attempts/50/submit', expect.objectContaining({
hints: [1],
@ -503,7 +506,7 @@ describe('quiz player', () => {
fireEvent.keyDown(window, { key: '1' })
await userEvent.click(await screen.findByRole('button', { name: 'stridor' }))
await userEvent.click(screen.getAllByRole('button', { name: 'Review & Complete' })[0])
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'End block' }))
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'End Block' }))
expect(await screen.findByText('Submitted results')).toBeInTheDocument()
expect(api.post).toHaveBeenCalledWith('/attempts/50/submit', expect.objectContaining({
hints: [],
@ -580,7 +583,7 @@ describe('quiz player', () => {
return originalPost(url, ...args)
})
await userEvent.click(screen.getAllByRole('button', { name: 'Review & Complete' })[0])
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'End block' }))
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'End Block' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Try again safely')
expect(api.delete).not.toHaveBeenCalled()
await userEvent.click(screen.getByRole('button', { name: 'Retry submission' }))

View file

@ -442,17 +442,19 @@ body:has(.quiz-player.is-boxed) .site-footer { display: none; }
.quiz-drawer-menu a:hover { background: var(--bg); }
/* Ending a block
A confirmation with one button is not a confirmation. The way back into
the block is offered first, and the unanswered are numbers you can press
rather than a list to be read. */
.quiz-end-lead { margin: 0 0 8px; font-size: 1rem; font-weight: 600; }
.quiz-end-note { margin: 0 0 14px; font-size: 0.86rem; line-height: 1.6; color: var(--text-muted); }
.quiz-end-missing { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 18px; }
.quiz-end-missing button {
min-width: 42px; min-height: 42px; padding: 0 12px;
font: inherit; font-weight: 700; cursor: pointer;
background: var(--wrong-bg); color: var(--wrong-fg);
border: 1px solid var(--wrong-bd); border-radius: 9px;
Leaving questions behind is the thing worth saying out loud, so an
incomplete block wears the warning in its heading and the button that
ends it is the one that looks like a consequence. */
.quiz-endblock p { margin: 0 0 10px; font-size: 0.92rem; line-height: 1.6; }
.quiz-endblock.is-warning h2 {
margin: -20px -20px 16px; padding: 12px 16px;
background: var(--wrong-fg); color: #fff;
border-radius: 12px 12px 0 0; font-size: 1rem;
}
.quiz-end-missing button:hover { border-color: var(--wrong-fg); }
.quiz-end-actions { display: flex; gap: 10px; flex-wrap: wrap; justify-content: flex-end; }
.quiz-endblock-go {
background: var(--wrong-fg); color: #fff; border: 1px solid var(--wrong-fg);
}
.quiz-endblock-go:hover:not(:disabled) { filter: brightness(1.1); }
/* Two buttons in a dialog sit side by side, the one that acts first. */
.quiz-away-actions { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; }