feat: exams are a group you can build, with the board's own blueprint

Four gaps, one change.

Articles could not belong to an exam at all — an article reached one only
by inference through its category, which cannot say that the same article
belongs to a basic-science step and a clinical one showing different
views in each. article_exam_links says whether it is in the group;
Exam.article_views already decided what is shown once you are there.

POST /exams/ wrote name, slug, sort order and active, and silently
dropped family, description and article views, so a new objective landed
in "Other" showing everything whatever was asked for. It writes what it
is given now, and PATCH can change it afterwards.

Membership was one link row at a time, which nobody would do for three
thousand questions. POST /exams/{id}/assign takes whole topics with
everything beneath them — questions and articles both — and is
idempotent, so widening a selection and running it again adds only what
is new.

And the point of all of it: a real paper is not a uniform draw. The ABP
publishes that 12% of a general paediatrics exam is preventive care and
2% is rheumatology; forty questions drawn evenly is forty coin flips.
exam_blueprints holds a board's published outline — its own numbering,
its headings, its weights — and blueprint_category_links maps it onto
our taxonomy rather than bending the tree to fit, because their outline
is arranged for examining and ours for studying.

The sampler uses largest-remainder, so twenty-four percentages still come
to forty questions, and a domain that cannot supply its share gives the
shortfall back to be spread over those that can — the paper keeps its
length and loses only accuracy, and the working is returned so the
shortfall is visible rather than silent.

Seeded from the ABP General Pediatrics Content Outline (Oct 2024):
structure and published weights only, no exam material. 120 lines, 22 of
24 domains mapped; Psychosocial Issues and Child Abuse and Neglect have
no category of ours and are reported rather than hidden.

Creating an objective is now an administrator's rather than a
moderator's: it appears in everyone's picker and scopes the whole bank,
which is site configuration, and it sits with the other site switches a
moderator cannot reach.

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-11 20:27:48 +02:00
parent d1de9589ad
commit 5c4823d9a4
14 changed files with 1980 additions and 12 deletions

View file

@ -0,0 +1,61 @@
"""Articles belong to exams; exams carry a published blueprint.
Three gaps closed at once. An article could only reach an exam by inference
through its category, which cannot say that the same article belongs to a basic
science step and a clinical one with different views in each. And nothing
recorded that a real paper is 12% preventive care and 2% rheumatology, so a
forty-question block was forty coin flips.
Revision ID: a7b8c9d0e1f2
Revises: f6a7b8c9d0e1
"""
import sqlalchemy as sa
from alembic import op
revision = "a7b8c9d0e1f2"
down_revision = "f6a7b8c9d0e1"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"article_exam_links",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column("article_id", sa.Integer(), sa.ForeignKey("articles.id", ondelete="CASCADE"), nullable=False),
sa.Column("exam_id", sa.Integer(), sa.ForeignKey("exams.id", ondelete="CASCADE"), nullable=False),
sa.UniqueConstraint("article_id", "exam_id", name="uq_article_exam"),
)
op.create_index("ix_article_exam_links_article_id", "article_exam_links", ["article_id"])
op.create_index("ix_article_exam_links_exam_id", "article_exam_links", ["exam_id"])
op.create_table(
"exam_blueprints",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column("exam_id", sa.Integer(), sa.ForeignKey("exams.id", ondelete="CASCADE"), nullable=False),
sa.Column("parent_id", sa.Integer(), sa.ForeignKey("exam_blueprints.id", ondelete="CASCADE"), nullable=True),
sa.Column("code", sa.String(16), nullable=False),
sa.Column("title", sa.String(240), nullable=False),
sa.Column("weight", sa.Numeric(5, 2), nullable=True),
sa.Column("sort_order", sa.Integer(), server_default="0"),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.UniqueConstraint("exam_id", "code", name="uq_exam_blueprint_code"),
)
op.create_index("ix_exam_blueprints_exam_id", "exam_blueprints", ["exam_id"])
op.create_index("ix_exam_blueprints_parent_id", "exam_blueprints", ["parent_id"])
op.create_table(
"blueprint_category_links",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column("blueprint_id", sa.Integer(), sa.ForeignKey("exam_blueprints.id", ondelete="CASCADE"), nullable=False),
sa.Column("category_id", sa.Integer(), sa.ForeignKey("question_categories.id", ondelete="CASCADE"), nullable=False),
sa.UniqueConstraint("blueprint_id", "category_id", name="uq_blueprint_category"),
)
op.create_index("ix_blueprint_category_links_blueprint_id", "blueprint_category_links", ["blueprint_id"])
op.create_index("ix_blueprint_category_links_category_id", "blueprint_category_links", ["category_id"])
def downgrade() -> None:
op.drop_table("blueprint_category_links")
op.drop_table("exam_blueprints")
op.drop_table("article_exam_links")

View file

@ -1,6 +1,6 @@
from datetime import datetime
from sqlalchemy import JSON, Column, DateTime, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy import JSON, Column, DateTime, ForeignKey, Integer, Numeric, String, UniqueConstraint
from app.database import Base
@ -38,3 +38,64 @@ class QuestionExamLink(Base):
id = Column(Integer, primary_key=True, index=True)
question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=False, index=True)
exam_id = Column(Integer, ForeignKey("exams.id", ondelete="CASCADE"), nullable=False, index=True)
class ArticleExamLink(Base):
"""Which reading belongs to which objective.
An article reached an exam only by inference through its category, which
cannot express the case that matters: the same article belongs to a basic
science step and to a clinical one, showing different views in each.
`Exam.article_views` decides what is shown once you are there; this decides
whether it is in the group at all.
"""
__tablename__ = "article_exam_links"
__table_args__ = (UniqueConstraint("article_id", "exam_id", name="uq_article_exam"),)
id = Column(Integer, primary_key=True, index=True)
article_id = Column(Integer, ForeignKey("articles.id", ondelete="CASCADE"), nullable=False, index=True)
exam_id = Column(Integer, ForeignKey("exams.id", ondelete="CASCADE"), nullable=False, index=True)
class ExamBlueprint(Base):
"""One line of an examining board's published content outline.
A real exam is not a uniform draw from a bank: the ABP publishes that 12%
of a general paediatrics paper is preventive care and 2% is rheumatology.
Without that, a forty-question block is forty coin flips and tells a
learner nothing about how they would do on the day.
`code` is the board's own numbering ("1", "4.A") so a domain can be matched
back to the published outline, and `weight` is a percentage of the whole
paper set on domains, left null on the subdomains beneath them.
"""
__tablename__ = "exam_blueprints"
__table_args__ = (UniqueConstraint("exam_id", "code", name="uq_exam_blueprint_code"),)
id = Column(Integer, primary_key=True, index=True)
exam_id = Column(Integer, ForeignKey("exams.id", ondelete="CASCADE"), nullable=False, index=True)
parent_id = Column(Integer, ForeignKey("exam_blueprints.id", ondelete="CASCADE"), nullable=True, index=True)
code = Column(String(16), nullable=False)
title = Column(String(240), nullable=False)
#: Percent of the paper. Null on a subdomain, which inherits its domain's share.
weight = Column(Numeric(5, 2), nullable=True)
sort_order = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow)
class BlueprintCategoryLink(Base):
"""Which of our categories feed one blueprint line.
The taxonomy is not reshaped to match a board's outline — it is arranged
for studying, and theirs is arranged for examining. This maps one onto the
other, so both can be right.
"""
__tablename__ = "blueprint_category_links"
__table_args__ = (UniqueConstraint("blueprint_id", "category_id", name="uq_blueprint_category"),)
id = Column(Integer, primary_key=True, index=True)
blueprint_id = Column(Integer, ForeignKey("exam_blueprints.id", ondelete="CASCADE"), nullable=False, index=True)
category_id = Column(Integer, ForeignKey("question_categories.id", ondelete="CASCADE"), nullable=False, index=True)

View file

@ -5,14 +5,19 @@ question can count towards a paediatrics board and a step exam, so membership is
a link table rather than a column on the question.
"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, Field
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.exam import Exam, QuestionExamLink
from app.models.article import Article
from app.models.exam import (ArticleExamLink, BlueprintCategoryLink, Exam,
ExamBlueprint, QuestionExamLink)
from app.models.question import Question
from app.models.question_category import QuestionCategory, QuestionCategoryLink
from app.models.user import User
from app.utils.auth import get_current_user, require_moderator
from app.services import exam_blueprint
from app.utils.auth import get_current_user, require_admin, require_moderator
router = APIRouter()
@ -83,17 +88,226 @@ def set_active_exam(
return {"active_exam_id": current_user.active_exam_id}
def _clean_views(views: list[str] | None) -> list[str] | None:
"""Null means every view. An empty or nonsense list would blank every
article, which is a mistake rather than a preference."""
if views is None:
return None
kept = [v for v in ARTICLE_VIEWS if v in views]
return kept or None
def _as_json(exam: Exam) -> dict:
return {"id": exam.id, "slug": exam.slug, "name": exam.name,
"family": exam.family, "description": exam.description,
"sort_order": exam.sort_order, "is_active": exam.is_active,
"article_views": views_for(exam)}
@router.post("/", status_code=201)
def create_exam(
data: ExamWrite,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
current_user: User = Depends(require_admin),
):
if db.query(Exam.id).filter(Exam.slug == data.slug).first():
"""Create an objective, with everything it was given.
This used to write name, slug, sort order and active, and silently drop
family, description and article views so a new objective landed in
"Other" showing every view, whatever was asked for.
"""
slug = data.slug.strip().lower()
if not slug:
raise HTTPException(422, "An objective needs a slug")
if db.query(Exam.id).filter(Exam.slug == slug).first():
raise HTTPException(409, "That slug is already in use")
exam = Exam(slug=data.slug.strip().lower(), name=data.name.strip(),
sort_order=data.sort_order, is_active=data.is_active)
exam = Exam(slug=slug, name=data.name.strip(), sort_order=data.sort_order,
is_active=data.is_active, family=(data.family or None),
description=(data.description or None),
article_views=_clean_views(data.article_views))
db.add(exam)
db.commit()
db.refresh(exam)
return {"id": exam.id, "slug": exam.slug, "name": exam.name}
return _as_json(exam)
class ExamEdit(BaseModel):
name: str | None = None
sort_order: int | None = None
is_active: int | None = None
family: str | None = None
description: str | None = None
article_views: list[str] | None = None
@router.patch("/{exam_id}")
def update_exam(
exam_id: int,
data: ExamEdit,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin),
):
exam = db.get(Exam, exam_id)
if not exam:
raise HTTPException(404, "Exam not found")
fields = data.model_dump(exclude_unset=True)
if "article_views" in fields:
fields["article_views"] = _clean_views(fields["article_views"])
# The slug is the address other things hold; it is not edited here.
for key, value in fields.items():
setattr(exam, key, value)
db.commit()
db.refresh(exam)
return _as_json(exam)
class AssignByCategory(BaseModel):
category_ids: list[int] = Field(min_length=1)
#: Whether to take everything beneath the named categories too. Almost
#: always yes — naming "Cardiology" and getting only what was filed
#: directly on it, not its conditions, is nobody's intent.
include_descendants: bool = True
questions: bool = True
articles: bool = True
@router.post("/{exam_id}/assign")
def assign_by_category(
exam_id: int,
data: AssignByCategory,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin),
):
"""Put everything under these categories into this objective.
Populating a new objective one link row at a time is not a thing anyone
would do for three thousand questions. Adding is idempotent running it
twice adds nothing the second time so it is safe to widen a selection
and run it again.
"""
exam = db.get(Exam, exam_id)
if not exam:
raise HTTPException(404, "Exam not found")
known = {row[0] for row in db.query(QuestionCategory.id).filter(
QuestionCategory.id.in_(data.category_ids)).all()}
missing = sorted(set(data.category_ids) - known)
if missing:
raise HTTPException(400, f"No such categor{'y' if len(missing) == 1 else 'ies'}: "
f"{', '.join(str(m) for m in missing)}")
scope = (exam_blueprint._descendants(db, list(known)) if data.include_descendants
else set(known))
added_questions = added_articles = 0
if data.questions:
linked = db.query(QuestionCategoryLink.question_id).filter(
QuestionCategoryLink.category_id.in_(scope))
ids = {row[0] for row in db.query(Question.id).filter(
Question.deleted_at.is_(None),
Question.question_category_id.in_(scope) | Question.id.in_(linked)).all()}
have = {row[0] for row in db.query(QuestionExamLink.question_id).filter(
QuestionExamLink.exam_id == exam_id).all()}
fresh = ids - have
db.add_all([QuestionExamLink(question_id=qid, exam_id=exam_id) for qid in fresh])
added_questions = len(fresh)
if data.articles:
ids = {row[0] for row in db.query(Article.id).filter(
Article.category_id.in_(scope)).all()}
have = {row[0] for row in db.query(ArticleExamLink.article_id).filter(
ArticleExamLink.exam_id == exam_id).all()}
fresh = ids - have
db.add_all([ArticleExamLink(article_id=aid, exam_id=exam_id) for aid in fresh])
added_articles = len(fresh)
db.commit()
return {"exam_id": exam_id, "categories": len(scope),
"questions_added": added_questions, "articles_added": added_articles}
@router.delete("/{exam_id}/assign")
def unassign_by_category(
exam_id: int,
data: AssignByCategory,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin),
):
"""Take them back out. The questions and articles are untouched — only
their membership of this objective goes."""
if not db.get(Exam, exam_id):
raise HTTPException(404, "Exam not found")
scope = (exam_blueprint._descendants(db, data.category_ids) if data.include_descendants
else set(data.category_ids))
removed_questions = removed_articles = 0
if data.questions:
linked = db.query(QuestionCategoryLink.question_id).filter(
QuestionCategoryLink.category_id.in_(scope))
ids = [row[0] for row in db.query(Question.id).filter(
Question.question_category_id.in_(scope) | Question.id.in_(linked)).all()]
removed_questions = db.query(QuestionExamLink).filter(
QuestionExamLink.exam_id == exam_id,
QuestionExamLink.question_id.in_(ids)).delete(synchronize_session=False)
if data.articles:
ids = [row[0] for row in db.query(Article.id).filter(Article.category_id.in_(scope)).all()]
removed_articles = db.query(ArticleExamLink).filter(
ArticleExamLink.exam_id == exam_id,
ArticleExamLink.article_id.in_(ids)).delete(synchronize_session=False)
db.commit()
return {"exam_id": exam_id, "questions_removed": removed_questions,
"articles_removed": removed_articles}
@router.get("/{exam_id}/blueprint")
def read_blueprint(
exam_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""The published shape of this exam, and what the bank can supply for it."""
if not db.get(Exam, exam_id):
raise HTTPException(404, "Exam not found")
rows = exam_blueprint.coverage(db, exam_id)
return {
"exam_id": exam_id,
"total_weight": exam_blueprint.total_weight(db, exam_id),
"domains": rows,
# Said plainly, because a domain weighted at 12% with nine questions
# behind it will never carry a paper.
"empty": [row["code"] for row in rows if row["questions"] == 0],
}
class BlueprintCategories(BaseModel):
category_ids: list[int]
@router.put("/{exam_id}/blueprint/{blueprint_id}/categories")
def set_blueprint_categories(
exam_id: int,
blueprint_id: int,
data: BlueprintCategories,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin),
):
"""Which of our categories feed one line of the outline.
Replaces rather than adds: the mapping is a statement of what this domain
is, and editing it by hand should not require remembering what was there.
"""
line = db.query(ExamBlueprint).filter(
ExamBlueprint.id == blueprint_id, ExamBlueprint.exam_id == exam_id).first()
if not line:
raise HTTPException(404, "No such blueprint line for this exam")
known = {row[0] for row in db.query(QuestionCategory.id).filter(
QuestionCategory.id.in_(data.category_ids)).all()}
missing = sorted(set(data.category_ids) - known)
if missing:
raise HTTPException(400, f"No such category: {', '.join(str(m) for m in missing)}")
db.query(BlueprintCategoryLink).filter(
BlueprintCategoryLink.blueprint_id == blueprint_id).delete(synchronize_session=False)
db.add_all([BlueprintCategoryLink(blueprint_id=blueprint_id, category_id=cid)
for cid in known])
db.commit()
return {"blueprint_id": blueprint_id, "category_ids": sorted(known)}

View file

@ -0,0 +1,178 @@
"""An exam's published shape, and drawing a paper that matches it.
A real paper is not a uniform draw from a bank. The ABP publishes that 12% of a
general paediatrics exam is preventive care and 2% is rheumatology; a block
drawn at random is forty coin flips, and tells a learner nothing about how they
would do on the day.
The blueprint is a mapping, not a reshaping. Our taxonomy is arranged for
studying and a board's outline is arranged for examining, and both are right —
so a blueprint line names its categories rather than the tree being bent to fit.
"""
from decimal import Decimal
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.exam import BlueprintCategoryLink, ExamBlueprint, QuestionExamLink
from app.models.question import Question
from app.models.question_category import QuestionCategory, QuestionCategoryLink
def _descendants(db: Session, category_ids: list[int]) -> set[int]:
"""Every category at or below the given ones."""
found = set(category_ids)
frontier = list(category_ids)
while frontier:
rows = db.query(QuestionCategory.id).filter(QuestionCategory.parent_id.in_(frontier)).all()
nxt = [row[0] for row in rows if row[0] not in found]
found.update(nxt)
frontier = nxt
return found
def domains(db: Session, exam_id: int) -> list[ExamBlueprint]:
"""The weighted lines — the domains, not the subdomains beneath them."""
return (db.query(ExamBlueprint)
.filter(ExamBlueprint.exam_id == exam_id, ExamBlueprint.parent_id.is_(None))
.order_by(ExamBlueprint.sort_order, ExamBlueprint.id).all())
def categories_for(db: Session, blueprint_id: int) -> set[int]:
direct = [row[0] for row in db.query(BlueprintCategoryLink.category_id)
.filter(BlueprintCategoryLink.blueprint_id == blueprint_id).all()]
return _descendants(db, direct) if direct else set()
def question_ids_for(db: Session, exam_id: int, category_ids: set[int],
predicate=None) -> list[int]:
"""Serveable questions of this exam that fall in these categories."""
if not category_ids:
return []
in_exam = select(QuestionExamLink.question_id).where(QuestionExamLink.exam_id == exam_id)
linked = select(QuestionCategoryLink.question_id).where(
QuestionCategoryLink.category_id.in_(category_ids))
query = db.query(Question.id).filter(
Question.id.in_(in_exam),
Question.question_category_id.in_(category_ids) | Question.id.in_(linked),
)
if predicate is not None:
query = query.filter(predicate)
return [row[0] for row in query.all()]
def allocate(weights: dict[int, Decimal], total: int, available: dict[int, int]) -> dict[int, int]:
"""How many questions each domain should contribute.
Largest-remainder rather than rounding each share independently: rounding
twenty-four percentages to whole questions loses or gains several, and a
forty-question block that returns thirty-six is a bug the learner sees.
A domain that cannot supply its share gives the shortfall back, and it is
redistributed over the domains that can so a thin corner of the bank
shrinks the paper's accuracy rather than its length.
"""
if total <= 0 or not weights:
return {}
share = sum(weights.values())
if share <= 0:
return {}
exact = {key: (Decimal(total) * weight / share) for key, weight in weights.items()}
plan = {key: int(value) for key, value in exact.items()}
# Largest remainder takes the seats whole numbers left over.
remainder = total - sum(plan.values())
for key, _ in sorted(exact.items(), key=lambda kv: kv[1] - int(kv[1]), reverse=True):
if remainder <= 0:
break
plan[key] += 1
remainder -= 1
# Give back what cannot be supplied, then re-offer it to whoever has room.
spare = 0
for key in list(plan):
cap = available.get(key, 0)
if plan[key] > cap:
spare += plan[key] - cap
plan[key] = cap
while spare > 0:
room = [key for key in plan if available.get(key, 0) > plan[key]]
if not room:
break
# In weight order, so the overflow lands where the exam is heaviest.
room.sort(key=lambda key: weights[key], reverse=True)
for key in room:
if spare <= 0:
break
plan[key] += 1
spare -= 1
return {key: count for key, count in plan.items() if count > 0}
def sample(db: Session, exam_id: int, total: int, predicate=None,
rng=None) -> tuple[list[int], list[dict]]:
"""A paper shaped like the real one, and the working that produced it.
Returns the question ids and a per-domain account what each domain was
owed and what it could give so the shortfall is visible rather than the
block quietly being the wrong shape.
"""
import random
rng = rng or random
lines = domains(db, exam_id)
if not lines:
return [], []
pools: dict[int, list[int]] = {}
weights: dict[int, Decimal] = {}
for line in lines:
if line.weight is None:
continue
weights[line.id] = Decimal(line.weight)
pools[line.id] = question_ids_for(db, exam_id, categories_for(db, line.id), predicate)
plan = allocate(weights, total, {key: len(ids) for key, ids in pools.items()})
chosen: list[int] = []
report = []
by_id = {line.id: line for line in lines}
for line_id, weight in sorted(weights.items(), key=lambda kv: kv[1], reverse=True):
want = plan.get(line_id, 0)
pool = pools.get(line_id, [])
picked = rng.sample(pool, min(want, len(pool))) if pool and want else []
chosen.extend(picked)
line = by_id[line_id]
report.append({
"code": line.code, "title": line.title, "weight": float(weight),
"asked_for": want, "given": len(picked), "pool": len(pool),
})
rng.shuffle(chosen)
return chosen, report
def coverage(db: Session, exam_id: int) -> list[dict]:
"""What each domain is owed and what the bank can actually supply.
The number worth knowing before building anything: a domain weighted at 12%
with nine questions behind it will never carry a paper, and an educator
should be told that rather than discovering it in a thin block.
"""
rows = []
for line in domains(db, exam_id):
cats = categories_for(db, line.id)
rows.append({
"id": line.id,
"code": line.code,
"title": line.title,
"weight": float(line.weight) if line.weight is not None else None,
"category_ids": sorted(cats),
"questions": len(question_ids_for(db, exam_id, cats)),
})
return rows
def total_weight(db: Session, exam_id: int) -> float:
value = db.query(func.coalesce(func.sum(ExamBlueprint.weight), 0)).filter(
ExamBlueprint.exam_id == exam_id, ExamBlueprint.parent_id.is_(None)).scalar()
return float(value or 0)

View file

@ -182,7 +182,9 @@ class GenerateTestRequest(TestOptions):
count: int = Field(ge=1, le=200)
expected_count: int | None = Field(default=None, ge=0)
difficulty: Literal["easy", "medium", "hard"] | None = None
algorithm: Literal["random", "adaptive"] = "random"
#: "blueprint" draws a paper shaped like the real exam — the examining
#: board's published weights, rather than a uniform draw from the bank.
algorithm: Literal["random", "adaptive", "blueprint"] = "random"
article_ids: list[int] = Field(default_factory=list)
tag_ids: list[int] = Field(default_factory=list)
#: Organ systems. Matched as "any tag beneath this system", where
@ -267,6 +269,8 @@ def adaptive_select(db, user, count, category_ids, state, difficulty):
def generate_test(db, user, data):
if data.algorithm == "blueprint":
return _blueprint_test(db, user, data)
if data.algorithm == "adaptive":
ids = adaptive_select(db, user, data.count, data.category_ids, data.state, data.difficulty)
if len(ids) < data.count:
@ -282,3 +286,33 @@ def generate_test(db, user, data):
if len(ids) < data.count:
raise HTTPException(400, f"Only {len(ids)} questions available; requested {data.count}")
return create_saved_test(db, user, data, random.sample(ids, data.count))
def _blueprint_test(db, user, data):
"""A paper shaped like the objective the learner is studying for.
Forty questions drawn evenly across a bank is forty coin flips; the same
forty drawn to the board's published weights is a rehearsal. What each
domain was owed and what it could give is returned with the test, so a
thin corner of the bank is visible rather than quietly changing the shape.
"""
from app.services import exam_blueprint
exam_id = getattr(user, "active_exam_id", None)
if not exam_id:
raise HTTPException(400, "Choose a study objective first — a blueprint belongs to an exam")
if not exam_blueprint.domains(db, exam_id):
raise HTTPException(400, "That objective has no blueprint yet")
ids, report = exam_blueprint.sample(
db, exam_id, data.count, predicate=bank_question_predicate(user) & general_question_predicate())
if not ids:
raise HTTPException(400, "No questions match this objective's blueprint")
quiz = create_saved_test(db, user, data, ids)
# Attached rather than merged, so a caller that does not know about
# blueprints is unaffected.
try:
quiz.blueprint_report = report
except Exception: # pragma: no cover — a plain schema object
pass
return quiz

View file

@ -0,0 +1,554 @@
[
{
"code": "1",
"title": "Preventive Pediatrics/Well-Child Care",
"weight": 12,
"subdomains": [
{
"code": "1.A",
"title": "Normal growth and development"
},
{
"code": "1.B",
"title": "Nutrition"
},
{
"code": "1.C",
"title": "Immunizations (based on the American Academy of Pediatrics schedule)"
},
{
"code": "1.D",
"title": "Screening and disease prevention"
},
{
"code": "1.E",
"title": "Anticipatory guidance"
}
]
},
{
"code": "2",
"title": "Fetal and Neonatal Care",
"weight": 4,
"subdomains": [
{
"code": "2.A",
"title": "Fetal care"
},
{
"code": "2.B",
"title": "Neonatal care"
}
]
},
{
"code": "3",
"title": "Adolescent Care",
"weight": 5,
"subdomains": [
{
"code": "3.A",
"title": "Growth and development"
},
{
"code": "3.B",
"title": "Sex and sexuality"
},
{
"code": "3.C",
"title": "Transition to adult care"
}
]
},
{
"code": "4",
"title": "Infectious Diseases",
"weight": 7,
"subdomains": [
{
"code": "4.A",
"title": "Specific pathogens"
},
{
"code": "4.B",
"title": "Special considerations"
}
]
},
{
"code": "5",
"title": "Mental and Behavioral Health",
"weight": 6,
"subdomains": [
{
"code": "5.A",
"title": "Cognition, language, learning, and neurodevelopment disorders and conditions"
},
{
"code": "5.B",
"title": "Psychologic/psychiatric disorders"
},
{
"code": "5.C",
"title": "Substance use/abuse (eg, stimulants, depressants, hallucinogens, prescription medications)"
},
{
"code": "5.D",
"title": "Tobacco use and vaping/e-cigarettes"
},
{
"code": "5.E",
"title": "Childhood/adolescent mental and behavioral issues"
}
]
},
{
"code": "6",
"title": "Psychosocial Issues",
"weight": 3,
"subdomains": [
{
"code": "6.A",
"title": "Family (eg, divorce, death, adoption, foster care)"
},
{
"code": "6.B",
"title": "Impact of illness (eg, chronic illness, disability)"
},
{
"code": "6.C",
"title": "Social determinants of health"
},
{
"code": "6.D",
"title": "Equity/inequity and racism in medicine"
},
{
"code": "6.E",
"title": "Social media (eg, cyberbullying, influence, self-esteem)"
}
]
},
{
"code": "7",
"title": "Emergency and Critical Care",
"weight": 4,
"subdomains": [
{
"code": "7.A",
"title": "Emergency medicine"
},
{
"code": "7.B",
"title": "Critical Care"
}
]
},
{
"code": "8",
"title": "Child Abuse and Neglect",
"weight": 3,
"subdomains": [
{
"code": "8.A",
"title": "Categories of abuse"
},
{
"code": "8.B",
"title": "Provider roles and responsibilities (eg, mandated reporting, family support)"
}
]
},
{
"code": "9",
"title": "Orthopedics and Sports Medicine",
"weight": 4,
"subdomains": [
{
"code": "9.A",
"title": "Orthopedic disorders and conditions"
},
{
"code": "9.B",
"title": "Sports medicine"
}
]
},
{
"code": "10",
"title": "Eye, Ear, Nose, and Throat",
"weight": 6,
"subdomains": [
{
"code": "10.A",
"title": "Diseases, disorders, and conditions of the eye"
},
{
"code": "10.B",
"title": "Diseases, disorders, and conditions of the ear"
},
{
"code": "10.C",
"title": "Diseases, disorders, and conditions of the nose and sinuses"
},
{
"code": "10.D",
"title": "Diseases, disorders, and conditions of the mouth, oropharynx, and throat"
},
{
"code": "10.E",
"title": "Diseases, disorders, and conditions of the neck"
}
]
},
{
"code": "11",
"title": "Cardiology",
"weight": 5,
"subdomains": [
{
"code": "11.A",
"title": "Syncope"
},
{
"code": "11.B",
"title": "Abnormal blood pressure and heart rate (eg, hypertension, postural orthostatic tachycardia"
},
{
"code": "11.C",
"title": "Structural heart disease"
},
{
"code": "11.D",
"title": "Dysrhythmias"
},
{
"code": "11.E",
"title": "Cardiomyopathies"
},
{
"code": "11.F",
"title": "Infection/inflammatory"
},
{
"code": "11.G",
"title": "Vasculitis (eg, multisystem inflammatory syndrome in children [MIS-C], Kawasaki syndrome)"
},
{
"code": "11.H",
"title": "Dyslipidemias"
}
]
},
{
"code": "12",
"title": "Pulmonology",
"weight": 5,
"subdomains": [
{
"code": "12.A",
"title": "Upper airway"
},
{
"code": "12.B",
"title": "Lower airway"
},
{
"code": "12.C",
"title": "Parenchymal, extrapulmonary, pulmonary hypertension, and cor pulmonale"
},
{
"code": "12.D",
"title": "Chronic lung disease"
}
]
},
{
"code": "13",
"title": "Gastroenterology",
"weight": 5,
"subdomains": [
{
"code": "13.A",
"title": "Diseases, disorders, and conditions"
},
{
"code": "13.B",
"title": "Systemic disorders"
},
{
"code": "13.C",
"title": "Functional disorders"
}
]
},
{
"code": "14",
"title": "Neurology",
"weight": 4,
"subdomains": [
{
"code": "14.A",
"title": "Brain disorders"
},
{
"code": "14.B",
"title": "Spinal cord disorders (infectious, inflammatory, anatomic)"
},
{
"code": "14.C",
"title": "Peripheral nervous system disorders"
},
{
"code": "14.D",
"title": "Sleep disorders"
},
{
"code": "14.E",
"title": "Muscular dystrophies"
},
{
"code": "14.F",
"title": "Neurocutaneous disorders"
},
{
"code": "14.G",
"title": "Degenerative neurologic disorders"
},
{
"code": "14.H",
"title": "Movement disorders"
}
]
},
{
"code": "15",
"title": "Skin/Dermatology",
"weight": 4,
"subdomains": [
{
"code": "15.A",
"title": "Congenital/neonatal disorders and conditions"
},
{
"code": "15.B",
"title": "Acquired disorders and conditions"
},
{
"code": "15.C",
"title": "Dermatologic manifestations of systemic disease"
}
]
},
{
"code": "16",
"title": "Hematology-Oncology",
"weight": 3,
"subdomains": [
{
"code": "16.A",
"title": "Hematologic diseases, disorders, and conditions"
},
{
"code": "16.B",
"title": "Malignancies"
},
{
"code": "16.C",
"title": "Special considerations"
}
]
},
{
"code": "17",
"title": "Allergy and Immunology",
"weight": 3,
"subdomains": [
{
"code": "17.A",
"title": "Allergic disorders"
},
{
"code": "17.B",
"title": "Disorders of immune function (eg, B cell, T cell, combined B- and T-cell defects, phagocytes,"
}
]
},
{
"code": "18",
"title": "Endocrinology",
"weight": 3,
"subdomains": [
{
"code": "18.A",
"title": "Adrenal, pituitary, and parathyroid disorders"
},
{
"code": "18.B",
"title": "Thyroid disorders"
},
{
"code": "18.C",
"title": "Diabetes insipidus"
},
{
"code": "18.D",
"title": "Type 1 diabetes"
},
{
"code": "18.E",
"title": "Type 2 diabetes/metabolic syndrome"
},
{
"code": "18.F",
"title": "Growth disorders (eg, tall stature, short stature, growth hormone deficiency)"
},
{
"code": "18.G",
"title": "Pubertal development (normal and abnormal)"
}
]
},
{
"code": "19",
"title": "Nephrology, Fluids, and Electrolytes",
"weight": 3,
"subdomains": [
{
"code": "19.A",
"title": "Renal physiology (eg, acid-base balance, electrolytes)"
},
{
"code": "19.B",
"title": "Diseases, disorders, and conditions"
},
{
"code": "19.C",
"title": "Hematuria"
},
{
"code": "19.D",
"title": "Proteinuria"
},
{
"code": "19.E",
"title": "Dehydration and fluid resuscitation"
}
]
},
{
"code": "20",
"title": "Genitourinary System",
"weight": 3,
"subdomains": [
{
"code": "20.A",
"title": "Disorders and conditions"
},
{
"code": "20.B",
"title": "Gynecology"
},
{
"code": "20.C",
"title": "Male genitalia"
}
]
},
{
"code": "21",
"title": "Genetics, Dysmorphology, and Metabolic Disorders",
"weight": 2,
"subdomains": [
{
"code": "21.A",
"title": "Genetic inheritance patterns (mendelian and non-mendelian)"
},
{
"code": "21.B",
"title": "Syndromes"
},
{
"code": "21.C",
"title": "Inborn errors of metabolism (eg, carbohydrate metabolism, lysosomal storage disorders, amino acid"
}
]
},
{
"code": "22",
"title": "Rheumatology",
"weight": 2,
"subdomains": [
{
"code": "22.A",
"title": "Postinfectious/reactive arthritis"
},
{
"code": "22.B",
"title": "Juvenile idiopathic arthritis (JIA)"
},
{
"code": "22.C",
"title": "Amplified pain syndromes (e.g, fibromyalgia, complex regional pain syndrome)"
},
{
"code": "22.D",
"title": "Connective tissue diseases"
},
{
"code": "22.E",
"title": "Systemic lupus erythematosus (SLE)"
},
{
"code": "22.F",
"title": "Dermatomyositis"
},
{
"code": "22.G",
"title": "Rheumatic fever, cyclic fever, and periodic fever syndrome"
}
]
},
{
"code": "23",
"title": "Ethics",
"weight": 2,
"subdomains": [
{
"code": "23.A",
"title": "Shared decision-making (eg, allocation of resources, informed consent)"
},
{
"code": "23.B",
"title": "Patient-parent-pediatrician relationship (eg, confidentiality, cross-cultural issues)"
},
{
"code": "23.C",
"title": "Professionalism and institutional ethics"
},
{
"code": "23.D",
"title": "Grief and communication"
}
]
},
{
"code": "24",
"title": "Patient Safety, Quality Improvement, and Research Methods",
"weight": 2,
"subdomains": [
{
"code": "24.A",
"title": "Patient safety and adverse events"
},
{
"code": "24.B",
"title": "Quality Improvement"
},
{
"code": "24.C",
"title": "Research methods"
}
]
}
]

View file

@ -0,0 +1,143 @@
"""Seed the ABP General Pediatrics content outline as a blueprint.
Source: the American Board of Pediatrics, "General Pediatrics Content Outline",
effective 15 October 2024 (minor revisions December 2024). Only the structure
is taken domain numbering, headings and the published exam weights, which are
the board's statement of what a paper contains. No exam material is reproduced.
The mapping below is the part that is ours. The board's outline is arranged for
examining and our taxonomy is arranged for studying, and neither should be bent
to fit the other, so this says which of our categories feed each domain. It is
a first pass by name; an administrator edits it from the exam screen, and
anything left unmapped is reported rather than hidden.
docker compose cp backend/scripts/seed_abp_blueprint.py backend:/app/seed.py
docker compose exec backend python /app/seed.py --exam pediatrics-boards [--apply]
"""
import argparse
import json
import os
import sys
from pathlib import Path
sys.path.insert(0, "/app")
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.models.exam import BlueprintCategoryLink, Exam, ExamBlueprint
from app.models.question_category import QuestionCategory
OUTLINE = Path(__file__).with_name("abp-2024-outline.json")
#: ABP domain code -> the root categories that feed it, by name. Everything
#: beneath a named category counts, so only the roots are listed.
MAPPING: dict[str, list[str]] = {
"1": ["Primary Care & Prevention"],
"2": ["Neonatology"],
"3": ["Adolescent Medicine"],
"4": ["Infectious Disease", "Sepsis"],
"5": ["Psychiatry & Psychology", "Developmental & Behavioral"],
# Poverty, family structure, school, grief. No root of ours holds these;
# left for an administrator rather than forced into a poor fit.
"6": [],
"7": ["Emergency Medicine", "Critical Care", "Toxicology"],
# Nor this one. Reported, not hidden.
"8": [],
"9": ["Orthopedics & Sports"],
"10": ["Ophthalmology", "Otolaryngology"],
"11": ["Cardiology"],
"12": ["Pulmonology"],
"13": ["Gastroenterology & Nutrition"],
"14": ["Neurology"],
"15": ["Dermatology"],
"16": ["Hematology-Oncology"],
"17": ["Allergy/Immunology"],
"18": ["Endocrinology"],
"19": ["Nephrology & Urology"],
# The board splits genitourinary from nephrology; we do not, so both point
# at the same root and the weights add up to the pair's real share.
"20": ["Nephrology & Urology", "Surgery"],
"21": ["Genetics & Metabolism"],
"22": ["Rheumatology"],
"23": ["Professional Topics"],
"24": ["Professional Topics"],
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--exam", default="pediatrics-boards")
parser.add_argument("--apply", action="store_true", help="write; otherwise report only")
args = parser.parse_args()
engine = create_engine(os.environ["DATABASE_URL"])
db = sessionmaker(bind=engine)()
exam = db.query(Exam).filter(Exam.slug == args.exam).first()
if not exam:
print(f"No exam with slug {args.exam!r}")
return 1
outline = json.loads(OUTLINE.read_text())
roots = {c.name: c.id for c in db.query(QuestionCategory).filter(
QuestionCategory.parent_id.is_(None)).all()}
unknown = sorted({name for names in MAPPING.values() for name in names} - set(roots))
if unknown:
print("Mapping names no such root category:", ", ".join(unknown))
return 1
existing = {row.code: row for row in db.query(ExamBlueprint).filter(
ExamBlueprint.exam_id == exam.id).all()}
written = mapped = 0
unmapped = []
for order, domain in enumerate(outline):
code = domain["code"]
line = existing.get(code)
if line is None:
line = ExamBlueprint(exam_id=exam.id, code=code, title=domain["title"],
weight=domain["weight"], sort_order=order)
if args.apply:
db.add(line)
db.flush()
written += 1
else:
line.title, line.weight, line.sort_order = domain["title"], domain["weight"], order
for sub_order, sub in enumerate(domain["subdomains"]):
if sub["code"] in existing:
continue
if args.apply:
db.add(ExamBlueprint(exam_id=exam.id, parent_id=line.id, code=sub["code"],
title=sub["title"], weight=None, sort_order=sub_order))
written += 1
names = MAPPING.get(code, [])
if not names:
unmapped.append(f"{code}. {domain['title']} ({domain['weight']}%)")
continue
if args.apply and line.id:
have = {row.category_id for row in db.query(BlueprintCategoryLink).filter(
BlueprintCategoryLink.blueprint_id == line.id).all()}
for name in names:
if roots[name] not in have:
db.add(BlueprintCategoryLink(blueprint_id=line.id, category_id=roots[name]))
mapped += 1
if args.apply:
db.commit()
print(f"{'Wrote' if args.apply else 'Would write'} {written} blueprint lines "
f"for {exam.name}; {mapped} of {len(outline)} domains mapped to categories.")
if unmapped:
print("\nNo category maps to these — an administrator should choose, or they")
print("will contribute nothing to a weighted paper:")
for row in unmapped:
print(" ", row)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,148 @@
"""Creating an objective, and putting things into it in bulk.
Two gaps this pins shut: create used to write four fields and silently drop
family, description and article views, so an objective never arrived as asked
for; and membership was one link row at a time, which nobody would do for three
thousand questions.
"""
import unittest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
import test_quiz_builder # noqa: F401 — imports every model
from app.database import Base, get_db
from app.models.article import Article
from app.models.exam import ArticleExamLink, Exam, QuestionExamLink
from app.models.question import Question
from app.models.question_category import QuestionCategory, QuestionCategoryLink
from app.models.user import User
from app.routers import exams
from app.utils.auth import get_current_user
class ExamAdminTests(unittest.TestCase):
def setUp(self):
self.engine = create_engine("sqlite://", connect_args={"check_same_thread": False},
poolclass=StaticPool)
Base.metadata.create_all(self.engine)
self.db = Session(self.engine)
self.admin = User(id=1, name="Admin", email="a@example.test",
hashed_password="unused", role="admin")
self.learner = User(id=2, name="Learner", email="l@example.test",
hashed_password="unused", role="user")
self.db.add_all([self.admin, self.learner])
self.db.add_all([QuestionCategory(id=1, name="Cardiology", user_id=1),
QuestionCategory(id=2, name="Congenital", parent_id=1, user_id=1),
QuestionCategory(id=3, name="Dermatology", user_id=1)])
self.db.flush()
for qid, category in [(1, 1), (2, 2), (3, 3)]:
self.db.add(Question(id=qid, question_category_id=category, user_id=1,
question_text=f"Q{qid}", question_type="mcq",
options=["a", "b"], correct_answer="a"))
self.db.add(Question(id=4, question_category_id=2, user_id=1, question_text="Gone",
question_type="mcq", options=["a"], correct_answer="a",
deleted_at=__import__("datetime").datetime(2026, 1, 1)))
self.db.add_all([Article(id=1, title="Tetralogy", slug="tetralogy", category_id=2),
Article(id=2, title="Eczema", slug="eczema", category_id=3)])
self.db.commit()
self.user = self.admin
app = FastAPI()
app.include_router(exams.router, prefix="/exams")
app.dependency_overrides[get_db] = lambda: self.db
app.dependency_overrides[get_current_user] = lambda: self.user
self.client = TestClient(app)
def tearDown(self):
self.client.close()
self.db.close()
self.engine.dispose()
def create(self, **overrides):
body = {"name": "USMLE Step 1", "slug": "usmle-step-1", "family": "USMLE",
"description": "Basic science.", "article_views": ["long"]}
body.update(overrides)
return self.client.post("/exams/", json=body)
def test_create_keeps_every_field_it_was_given(self):
body = self.create().json()
self.assertEqual(body["family"], "USMLE")
self.assertEqual(body["description"], "Basic science.")
self.assertEqual(body["article_views"], ["long"])
def test_an_objective_that_would_show_nothing_shows_everything_instead(self):
# A view list naming nothing real would leave every article blank,
# which is a mistake rather than a preference worth honouring.
body = self.create(article_views=["bedside"]).json()
self.assertEqual(body["article_views"], ["short", "long", "clinical"])
def test_a_repeated_slug_is_refused(self):
self.assertEqual(self.create().status_code, 201)
self.assertEqual(self.create(name="Another").status_code, 409)
def test_only_an_administrator_may_create_one(self):
self.user = self.learner
self.assertEqual(self.create().status_code, 403)
def test_editing_changes_what_was_named_and_nothing_else(self):
exam_id = self.create().json()["id"]
body = self.client.patch(f"/exams/{exam_id}", json={"family": "Steps"}).json()
self.assertEqual(body["family"], "Steps")
self.assertEqual(body["description"], "Basic science.")
self.assertEqual(body["slug"], "usmle-step-1")
def test_assigning_a_category_takes_everything_beneath_it(self):
exam_id = self.create().json()["id"]
body = self.client.post(f"/exams/{exam_id}/assign",
json={"category_ids": [1]}).json()
# Questions 1 and 2 — the root and its child. Not 3, and not the
# deleted one, which is not in the bank any more.
self.assertEqual(body["questions_added"], 2)
self.assertEqual(body["articles_added"], 1)
linked = {row.question_id for row in self.db.query(QuestionExamLink).all()}
self.assertEqual(linked, {1, 2})
def test_not_taking_descendants_takes_only_what_was_named(self):
exam_id = self.create().json()["id"]
body = self.client.post(f"/exams/{exam_id}/assign",
json={"category_ids": [1], "include_descendants": False}).json()
self.assertEqual(body["questions_added"], 1)
def test_assigning_twice_adds_nothing_the_second_time(self):
exam_id = self.create().json()["id"]
self.client.post(f"/exams/{exam_id}/assign", json={"category_ids": [1]})
again = self.client.post(f"/exams/{exam_id}/assign", json={"category_ids": [1]}).json()
self.assertEqual(again["questions_added"], 0)
self.assertEqual(again["articles_added"], 0)
def test_articles_can_belong_to_two_objectives_at_once(self):
step = self.create().json()["id"]
ck = self.create(name="Step 2 CK", slug="usmle-step-2", article_views=["clinical"]).json()["id"]
for exam_id in (step, ck):
self.client.post(f"/exams/{exam_id}/assign", json={"category_ids": [2]})
rows = self.db.query(ArticleExamLink).filter(ArticleExamLink.article_id == 1).all()
self.assertEqual(sorted(row.exam_id for row in rows), sorted([step, ck]))
def test_unassigning_removes_membership_and_leaves_the_questions(self):
exam_id = self.create().json()["id"]
self.client.post(f"/exams/{exam_id}/assign", json={"category_ids": [1]})
body = self.client.request("DELETE", f"/exams/{exam_id}/assign",
json={"category_ids": [1]}).json()
self.assertEqual(body["questions_removed"], 2)
self.assertEqual(self.db.query(QuestionExamLink).count(), 0)
self.assertEqual(self.db.query(Question).filter(Question.deleted_at.is_(None)).count(), 3)
def test_a_category_that_does_not_exist_is_refused_by_name(self):
exam_id = self.create().json()["id"]
response = self.client.post(f"/exams/{exam_id}/assign", json={"category_ids": [1, 99]})
self.assertEqual(response.status_code, 400)
self.assertIn("99", response.json()["detail"])
self.assertEqual(self.db.query(QuestionExamLink).count(), 0, "nothing written on refusal")
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,135 @@
"""An exam's published shape, and drawing a paper that matches it.
The arithmetic is the part worth pinning. Twenty-four percentages rounded
independently do not add up to forty questions, and a domain with a thin corner
of the bank behind it must give its shortfall back rather than shortening the
paper.
"""
import random
import unittest
from decimal import Decimal
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
import test_quiz_builder # noqa: F401 — imports every model, so the metadata resolves
from app.database import Base
from app.models.exam import BlueprintCategoryLink, Exam, ExamBlueprint, QuestionExamLink
from app.models.question import Question
from app.models.question_category import QuestionCategory
from app.models.user import User
from app.services import exam_blueprint
class AllocationTests(unittest.TestCase):
"""No database: this is the arithmetic on its own."""
def test_shares_add_up_to_the_whole_paper(self):
# Twenty-four domains of the real outline, rounded independently, do
# not come to forty. Largest remainder is why the block is never short.
weights = {i: Decimal(w) for i, w in enumerate(
[12, 4, 5, 7, 6, 3, 4, 3, 4, 6, 5, 5, 5, 4, 4, 3, 3, 3, 3, 3, 2, 2, 2, 2])}
plan = exam_blueprint.allocate(weights, 40, {i: 999 for i in weights})
self.assertEqual(sum(plan.values()), 40)
self.assertEqual(plan[0], 5) # 12% of 40 is 4.8, and the remainder carries it
self.assertEqual(plan[23], 1) # 2% of 40 is 0.8 — still worth a question
def test_a_thin_domain_gives_its_shortfall_back(self):
weights = {1: Decimal(50), 2: Decimal(50)}
plan = exam_blueprint.allocate(weights, 20, {1: 3, 2: 100})
self.assertEqual(plan[1], 3)
# The paper stays twenty questions long; it is its accuracy that suffers.
self.assertEqual(sum(plan.values()), 20)
def test_overflow_lands_where_the_exam_is_heaviest(self):
weights = {1: Decimal(10), 2: Decimal(60), 3: Decimal(30)}
plan = exam_blueprint.allocate(weights, 10, {1: 0, 2: 100, 3: 100})
self.assertNotIn(1, plan)
self.assertEqual(sum(plan.values()), 10)
self.assertGreater(plan[2], plan[3])
def test_nothing_to_draw_from_returns_nothing_rather_than_looping(self):
weights = {1: Decimal(100)}
self.assertEqual(exam_blueprint.allocate(weights, 10, {1: 0}), {})
self.assertEqual(exam_blueprint.allocate({}, 10, {}), {})
self.assertEqual(exam_blueprint.allocate(weights, 0, {1: 5}), {})
class BlueprintTests(unittest.TestCase):
def setUp(self):
self.engine = create_engine("sqlite://", connect_args={"check_same_thread": False},
poolclass=StaticPool)
Base.metadata.create_all(self.engine)
self.db = Session(self.engine)
self.db.add(User(id=1, name="Mod", email="m@example.test",
hashed_password="unused", role="moderator"))
self.exam = Exam(id=1, slug="boards", name="Boards")
self.db.add(self.exam)
# Cardiology holds its questions two levels down, which is the case the
# naive count got wrong: filed on the root, nothing would be found.
self.db.add_all([
QuestionCategory(id=1, name="Cardiology", user_id=1),
QuestionCategory(id=2, name="Congenital", parent_id=1, user_id=1),
QuestionCategory(id=3, name="Tetralogy", parent_id=2, user_id=1),
QuestionCategory(id=4, name="Dermatology", user_id=1),
])
self.db.flush()
for qid in range(1, 21):
category = 3 if qid <= 15 else 4
self.db.add(Question(id=qid, question_category_id=category, user_id=1,
question_text=f"Q{qid}", question_type="mcq",
options=["a", "b"], correct_answer="a"))
self.db.add(QuestionExamLink(question_id=qid, exam_id=1))
self.cardio = ExamBlueprint(id=1, exam_id=1, code="11", title="Cardiology",
weight=Decimal("75"), sort_order=0)
self.derm = ExamBlueprint(id=2, exam_id=1, code="15", title="Skin",
weight=Decimal("25"), sort_order=1)
self.db.add_all([self.cardio, self.derm])
self.db.flush()
self.db.add_all([BlueprintCategoryLink(blueprint_id=1, category_id=1),
BlueprintCategoryLink(blueprint_id=2, category_id=4)])
self.db.commit()
def tearDown(self):
self.db.close()
self.engine.dispose()
def test_a_domain_counts_everything_beneath_its_categories(self):
rows = {row["code"]: row for row in exam_blueprint.coverage(self.db, 1)}
# Mapped to the Cardiology root; the questions are on a grandchild.
self.assertEqual(rows["11"]["questions"], 15)
self.assertEqual(rows["15"]["questions"], 5)
def test_subdomains_carry_no_weight_of_their_own(self):
self.db.add(ExamBlueprint(id=3, exam_id=1, parent_id=1, code="11.A",
title="Congenital", weight=None))
self.db.commit()
self.assertEqual([line.code for line in exam_blueprint.domains(self.db, 1)], ["11", "15"])
self.assertEqual(exam_blueprint.total_weight(self.db, 1), 100.0)
def test_a_paper_comes_out_shaped_like_the_blueprint(self):
ids, report = exam_blueprint.sample(self.db, 1, 8, rng=random.Random(1))
self.assertEqual(len(ids), 8)
self.assertEqual(len(set(ids)), 8, "no question twice")
given = {row["code"]: row["given"] for row in report}
self.assertEqual(given["11"], 6) # 75% of 8
self.assertEqual(given["15"], 2) # 25% of 8
def test_an_unmapped_domain_is_named_rather_than_silently_ignored(self):
self.db.add(ExamBlueprint(id=4, exam_id=1, code="6", title="Psychosocial",
weight=Decimal("10"), sort_order=2))
self.db.commit()
rows = {row["code"]: row for row in exam_blueprint.coverage(self.db, 1)}
self.assertEqual(rows["6"]["questions"], 0)
self.assertEqual(rows["6"]["category_ids"], [])
def test_an_exam_with_no_blueprint_draws_nothing_rather_than_guessing(self):
other = Exam(id=2, slug="step", name="Step")
self.db.add(other)
self.db.commit()
self.assertEqual(exam_blueprint.sample(self.db, 2, 40), ([], []))
if __name__ == "__main__":
unittest.main()

View file

@ -30,7 +30,8 @@ class ExamTests(unittest.TestCase):
self.user = User(id=1, name="Learner", email="learner@example.test", hashed_password="unused")
self.mod = User(id=2, name="Mod", email="mod@example.test", hashed_password="unused", role="moderator")
self.db.add_all([self.user, self.mod])
self.admin = User(id=4, name="Admin", email="admin@example.test", hashed_password="unused", role="admin")
self.db.add_all([self.user, self.mod, self.admin])
self.db.add_all([
Exam(id=1, slug="pediatrics-boards", name="Pediatrics Boards", sort_order=10),
Exam(id=2, slug="usmle-step-2-ck", name="USMLE Step 2 CK", sort_order=20),
@ -94,10 +95,15 @@ class ExamTests(unittest.TestCase):
self.db.commit()
self.assertEqual(self.bank_ids(), {2, 3})
def test_creating_an_exam_is_moderator_only(self):
def test_creating_an_exam_is_an_administrator_s(self):
# An objective appears in everyone's picker and scopes the whole bank,
# so it is site configuration rather than content — and it lives with
# the other site switches, which a moderator cannot reach either.
body = {"name": "USMLE Step 1", "slug": "usmle-step-1"}
self.assertEqual(self.client.post("/exams/", json=body).status_code, 403)
self.user = self.mod
self.assertEqual(self.client.post("/exams/", json=body).status_code, 403)
self.user = self.admin
self.assertEqual(self.client.post("/exams/", json=body).status_code, 201)
self.assertEqual(self.client.post("/exams/", json=body).status_code, 409)

View file

@ -0,0 +1,82 @@
.exa { display: flex; flex-direction: column; gap: 14px; }
.exa-error { margin: 0; font-size: 0.85rem; color: var(--wrong-fg); }
.exa-notice { margin: 0; font-size: 0.85rem; color: var(--right-fg, #15803d); }
.exa-hint { margin: 6px 0 0; font-size: 0.8rem; color: var(--text-muted); }
.exa-warn {
margin: 0 0 10px; padding: 9px 12px;
font-size: 0.82rem; line-height: 1.5;
color: var(--wrong-fg); background: var(--wrong-bg);
border: 1px solid var(--wrong-bd); border-radius: 8px;
}
/* ── Creating one ─────────────────────────────────────────────────── */
.exa-new {
padding: 16px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg);
}
.exa-new small { display: block; margin-top: 4px; font-size: 0.78rem; color: var(--text-muted); }
.exa-views { margin: 4px 0 14px; padding: 12px 14px; border: 1px solid var(--border); border-radius: 8px; }
.exa-views legend {
padding: 0 6px;
font-size: 0.66rem; font-weight: 700; letter-spacing: 0.07em;
text-transform: uppercase; color: var(--text-subtle);
}
.exa-views label { display: flex; align-items: flex-start; gap: 9px; padding: 5px 0; cursor: pointer; }
.exa-views label span { font-size: 0.88rem; font-weight: 600; }
.exa-views label small { display: block; font-weight: 400; font-size: 0.78rem; color: var(--text-muted); }
.exa-actions { display: flex; gap: 8px; flex-wrap: wrap; }
/* ── The list ─────────────────────────────────────────────────────── */
.exa-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.exa-list > li { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
.exa-list > li.is-open { border-color: var(--primary); }
.exa-row {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
width: 100%; padding: 13px 15px;
font: inherit; text-align: left; cursor: pointer;
background: var(--card-bg); border: 0; color: var(--text);
}
.exa-row:hover { background: var(--bg); }
.exa-row strong { display: block; font-size: 0.95rem; font-weight: 650; }
.exa-row small { display: block; margin-top: 2px; font-size: 0.8rem; color: var(--text-muted); }
.exa-body { padding: 4px 15px 18px; border-top: 1px solid var(--border); }
.exa-desc { margin: 12px 0; font-size: 0.86rem; color: var(--text-muted); line-height: 1.6; }
.exa-label {
font-size: 0.66rem; font-weight: 700; letter-spacing: 0.07em;
text-transform: uppercase; color: var(--text-subtle);
}
.exa-views-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 14px 0 4px; }
.exa-chip {
display: inline-flex; align-items: center; gap: 6px;
padding: 5px 11px; font-size: 0.83rem; cursor: pointer;
border: 1px solid var(--border); border-radius: 999px; background: var(--card-bg);
}
.exa-chip:has(input:checked) { border-color: var(--primary); color: var(--primary); font-weight: 600; }
.exa-assign, .exa-blueprint { margin-top: 20px; }
.exa-assign h4, .exa-blueprint h4 {
display: flex; align-items: baseline; justify-content: space-between; gap: 12px;
margin: 0 0 6px; font-size: 0.95rem; font-weight: 650;
}
.exa-blueprint h4 small { font-size: 0.78rem; font-weight: 400; color: var(--text-muted); }
.exa-assign p { margin: 0 0 10px; font-size: 0.83rem; color: var(--text-muted); line-height: 1.55; }
.exa-assign .btn { margin-top: 10px; }
.exa-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.exa-table th {
padding: 7px 8px; text-align: left; font-size: 0.66rem; font-weight: 700;
letter-spacing: 0.07em; text-transform: uppercase; color: var(--text-subtle);
border-bottom: 1px solid var(--border);
}
.exa-table td { padding: 8px; border-bottom: 1px solid var(--border); }
.exa-table th:not(:first-child), .exa-table td:not(:first-child) { text-align: right; white-space: nowrap; }
.exa-table tr.is-empty td { color: var(--wrong-fg); }
.exa-code {
display: inline-block; min-width: 24px; margin-right: 6px;
font-size: 0.74rem; font-weight: 700; color: var(--text-subtle);
}
@media (max-width: 560px) {
.exa-table { font-size: 0.8rem; }
.exa-table td:first-child { max-width: 0; }
}

View file

@ -0,0 +1,252 @@
import { useCallback, useEffect, useState } from 'react'
import api from '../api/client'
import CategoryTree from './CategoryTree'
import './ExamAdmin.css'
const VIEWS = [
{ key: 'short', label: 'Short', hint: 'The one-screen version' },
{ key: 'long', label: 'Long', hint: 'The full article' },
{ key: 'clinical', label: 'Clinical', hint: 'Paths, diagnosis, management' },
]
const detail = (err, fallback) => {
const value = err?.response?.data?.detail
return typeof value === 'string' ? value : fallback
}
const slugify = name => (name || '').toLowerCase().trim()
.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 80)
/**
* The exams a learner can be revising for.
*
* Creating one was an API call that quietly dropped half of what it was given,
* and filling one meant a link row per question. Both are here now, alongside
* the thing that makes an objective worth having: its blueprint the
* examining board's published weights and whether the bank can actually
* carry them.
*/
export default function ExamAdmin() {
const [exams, setExams] = useState([])
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [creating, setCreating] = useState(false)
const [draft, setDraft] = useState({ name: '', slug: '', family: '', description: '', views: [] })
const [openId, setOpenId] = useState(null)
const [blueprint, setBlueprint] = useState(null)
const [assignTo, setAssignTo] = useState([])
const [categories, setCategories] = useState([])
const load = useCallback(() => {
api.get('/exams/')
.then(res => setExams(res.data?.exams || []))
.catch(() => setError('Could not load the objectives'))
.finally(() => setLoading(false))
}, [])
useEffect(() => { load() }, [load])
useEffect(() => {
api.get('/question-categories/').then(res => setCategories(res.data || [])).catch(() => setCategories([]))
}, [])
useEffect(() => {
if (!openId) { setBlueprint(null); return undefined }
let live = true
setBlueprint(null)
api.get(`/exams/${openId}/blueprint`)
.then(res => { if (live) setBlueprint(res.data) })
.catch(() => { if (live) setBlueprint({ domains: [], total_weight: 0, empty: [] }) })
return () => { live = false }
}, [openId])
const run = async (fn, failure, message) => {
setBusy(true); setError(''); setNotice('')
try {
const result = await fn()
if (message) setNotice(message(result))
load()
return result
} catch (err) { setError(detail(err, failure)) }
finally { setBusy(false) }
}
const create = () => run(
() => api.post('/exams/', {
name: draft.name.trim(),
slug: draft.slug.trim() || slugify(draft.name),
family: draft.family.trim() || null,
description: draft.description.trim() || null,
// Nothing chosen means every view, which is what null says.
article_views: draft.views.length ? draft.views : null,
}).then(res => { setCreating(false); setDraft({ name: '', slug: '', family: '', description: '', views: [] }); return res }),
'Could not create that objective',
res => `Created “${res.data.name}”. It has no questions yet — assign some below.`)
const setViews = (exam, key) => {
const next = (exam.article_views || []).includes(key)
? exam.article_views.filter(v => v !== key)
: [...(exam.article_views || []), key]
return run(() => api.patch(`/exams/${exam.id}`, { article_views: next.length ? next : null }),
'Could not change the views')
}
const assign = (exam) => run(
() => api.post(`/exams/${exam.id}/assign`, { category_ids: assignTo }),
'Could not assign those categories',
res => `Added ${res.data.questions_added} question${res.data.questions_added === 1 ? '' : 's'} ` +
`and ${res.data.articles_added} article${res.data.articles_added === 1 ? '' : 's'} to “${exam.name}”.`)
if (loading) return <div className="loading"><div className="spinner" /></div>
return (
<div className="exa">
{error && <p className="exa-error" role="alert">{error}</p>}
{notice && <p className="exa-notice" role="status">{notice}</p>}
{creating ? (
<div className="exa-new">
<div className="form-group">
<label htmlFor="exa-name">Name</label>
<input id="exa-name" value={draft.name} placeholder="USMLE Step 1"
onChange={e => setDraft(d => ({ ...d, name: e.target.value }))} />
</div>
<div className="form-group">
<label htmlFor="exa-family">Family</label>
<input id="exa-family" value={draft.family} placeholder="USMLE"
onChange={e => setDraft(d => ({ ...d, family: e.target.value }))} />
<small>The picker groups by this. Left blank, it lands in Other.</small>
</div>
<div className="form-group">
<label htmlFor="exa-slug">Address</label>
<input id="exa-slug" value={draft.slug} placeholder={slugify(draft.name) || 'usmle-step-1'}
onChange={e => setDraft(d => ({ ...d, slug: e.target.value }))} />
<small>Fixed once created other things hold it.</small>
</div>
<div className="form-group">
<label htmlFor="exa-desc">Description</label>
<input id="exa-desc" value={draft.description} placeholder="What this objective covers"
onChange={e => setDraft(d => ({ ...d, description: e.target.value }))} />
</div>
<fieldset className="exa-views">
<legend>Article views</legend>
{/* Someone revising a basic-science step has no use for bedside
dosing, and a view they can open but must never act on is worse
than one they were never offered. */}
{VIEWS.map(view => (
<label key={view.key}>
<input type="checkbox" checked={draft.views.includes(view.key)}
onChange={e => setDraft(d => ({
...d,
views: e.target.checked ? [...d.views, view.key] : d.views.filter(v => v !== view.key),
}))} />
<span>{view.label}<small>{view.hint}</small></span>
</label>
))}
<p className="exa-hint">None chosen means all three.</p>
</fieldset>
<div className="exa-actions">
<button type="button" className="btn btn-primary" disabled={busy || !draft.name.trim()}
onClick={create}>Create objective</button>
<button type="button" className="btn btn-secondary" onClick={() => setCreating(false)}>Cancel</button>
</div>
</div>
) : (
<button type="button" className="btn btn-primary" onClick={() => setCreating(true)}>
+ New objective
</button>
)}
<ul className="exa-list">
{exams.map(exam => (
<li key={exam.id} className={openId === exam.id ? 'is-open' : undefined}>
<button type="button" className="exa-row" aria-expanded={openId === exam.id}
onClick={() => setOpenId(id => (id === exam.id ? null : exam.id))}>
<span>
<strong>{exam.name}</strong>
<small>{exam.family} · {exam.question_count} questions</small>
</span>
<span aria-hidden="true">{openId === exam.id ? '⌃' : '⌄'}</span>
</button>
{openId === exam.id && (
<div className="exa-body">
{exam.description && <p className="exa-desc">{exam.description}</p>}
<div className="exa-views-row">
<span className="exa-label">Article views</span>
{VIEWS.map(view => (
<label key={view.key} className="exa-chip">
<input type="checkbox" disabled={busy}
checked={(exam.article_views || []).includes(view.key)}
onChange={() => setViews(exam, view.key)} />
<span>{view.label}</span>
</label>
))}
</div>
<section className="exa-assign">
<h4>Add content by topic</h4>
<p>
Everything beneath the topics you pick questions and
articles both joins this objective. Running it again after
widening the selection adds only what is new.
</p>
<CategoryTree categories={categories} selectedIds={assignTo} searchable
onToggle={(id, on) => setAssignTo(prev =>
on ? [...prev, id] : prev.filter(x => x !== id))} />
<button type="button" className="btn btn-primary btn-sm"
disabled={busy || assignTo.length === 0} onClick={() => assign(exam)}>
Add {assignTo.length || 'no'} topic{assignTo.length === 1 ? '' : 's'}
</button>
</section>
<section className="exa-blueprint">
<h4>
Blueprint
{blueprint && <small>{blueprint.total_weight}% accounted for</small>}
</h4>
{!blueprint ? (
<div className="loading"><div className="spinner" /></div>
) : blueprint.domains.length === 0 ? (
<p className="exa-hint">
No blueprint. Questions are drawn evenly, which is not the
shape of a real paper.
</p>
) : (
<>
{blueprint.empty.length > 0 && (
<p className="exa-warn" role="status">
{blueprint.empty.length} domain{blueprint.empty.length === 1 ? '' : 's'} with
{' '}nothing behind {blueprint.empty.length === 1 ? 'it' : 'them'}. Their share is
{' '}spread across the rest, so a paper will not have the shape it claims until
{' '}topics are mapped to {blueprint.empty.length === 1 ? 'it' : 'them'}.
</p>
)}
<table className="exa-table">
<thead>
<tr><th>Domain</th><th>Weight</th><th>In the bank</th></tr>
</thead>
<tbody>
{blueprint.domains.map(row => (
<tr key={row.id} className={row.questions === 0 ? 'is-empty' : undefined}>
<td><span className="exa-code">{row.code}</span> {row.title}</td>
<td>{row.weight}%</td>
<td>{row.questions === 0 ? 'none' : row.questions}</td>
</tr>
))}
</tbody>
</table>
</>
)}
</section>
</div>
)}
</li>
))}
</ul>
</div>
)
}

View file

@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ExamAdmin from './ExamAdmin'
import api from '../api/client'
vi.mock('../api/client', () => ({
default: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() },
}))
const EXAMS = [{ id: 1, name: 'Pediatrics Boards', family: 'Boards', question_count: 2948,
description: 'The ABP general paediatrics paper.', article_views: ['short', 'long', 'clinical'] }]
const BLUEPRINT = {
exam_id: 1, total_weight: 100,
domains: [
{ id: 11, code: '1', title: 'Preventive Pediatrics', weight: 12, questions: 224 },
{ id: 12, code: '6', title: 'Psychosocial Issues', weight: 3, questions: 0 },
],
empty: ['6'],
}
beforeEach(() => {
vi.clearAllMocks()
api.get.mockImplementation(url => {
if (url === '/exams/') return Promise.resolve({ data: { exams: EXAMS } })
if (url === '/exams/1/blueprint') return Promise.resolve({ data: BLUEPRINT })
if (url === '/question-categories/') return Promise.resolve({
data: [{ id: 1, name: 'Cardiology', parent_id: null }] })
return Promise.resolve({ data: [] })
})
api.post.mockResolvedValue({ data: { id: 2, name: 'USMLE Step 1', questions_added: 0, articles_added: 0 } })
api.patch.mockResolvedValue({ data: {} })
})
const open = async () => {
render(<ExamAdmin />)
await userEvent.click(await screen.findByRole('button', { name: /Pediatrics Boards/ }))
}
describe('exam administration', () => {
it('creates an objective with every field, not just name and slug', async () => {
render(<ExamAdmin />)
await userEvent.click(await screen.findByRole('button', { name: '+ New objective' }))
await userEvent.type(screen.getByLabelText('Name'), 'USMLE Step 1')
await userEvent.type(screen.getByLabelText('Family'), 'USMLE')
await userEvent.type(screen.getByLabelText('Description'), 'Basic science.')
await userEvent.click(screen.getByRole('checkbox', { name: /Long/ }))
await userEvent.click(screen.getByRole('button', { name: 'Create objective' }))
// The old endpoint dropped family, description and views on the floor.
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/exams/', {
name: 'USMLE Step 1',
slug: 'usmle-step-1',
family: 'USMLE',
description: 'Basic science.',
article_views: ['long'],
}))
})
it('sends no views at all when none are ticked, which means all of them', async () => {
render(<ExamAdmin />)
await userEvent.click(await screen.findByRole('button', { name: '+ New objective' }))
await userEvent.type(screen.getByLabelText('Name'), 'Shelf')
await userEvent.click(screen.getByRole('button', { name: 'Create objective' }))
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/exams/',
expect.objectContaining({ article_views: null })))
})
it('assigns whole topics rather than one question at a time', async () => {
await open()
await userEvent.click(await screen.findByRole('checkbox', { name: /Cardiology/ }))
await userEvent.click(screen.getByRole('button', { name: 'Add 1 topic' }))
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/exams/1/assign',
{ category_ids: [1] }))
})
it('shows the published weights and what the bank can supply', async () => {
await open()
const table = await screen.findByRole('table')
expect(within(table).getByText('12%')).toBeInTheDocument()
expect(within(table).getByText('224')).toBeInTheDocument()
})
it('says plainly when a weighted domain has nothing behind it', async () => {
await open()
// A domain weighted at 3% with no questions will never carry its share,
// and an educator should be told rather than find a thin block.
expect(await screen.findByText(/1 domain with nothing behind it/)).toBeInTheDocument()
expect(screen.getByText('none')).toBeInTheDocument()
})
})

View file

@ -5,6 +5,7 @@ import { useTheme } from '../context/ThemeContext'
import api from '../api/client'
import ExamSwitcher from '../components/ExamSwitcher'
import SitePolicy from '../components/SitePolicy'
import ExamAdmin from '../components/ExamAdmin'
import lazyPage from '../utils/lazyPage'
// Only an administrator ever renders this, and it is the largest thing on the
@ -437,6 +438,13 @@ export default function SettingsPage() {
...(isAdmin ? [
{ key: 'policy', group: 'The site', icon: '🔒', label: 'Access and joining',
render: () => <SitePolicySection /> },
{ key: 'exams', group: 'The site', icon: '🎓', label: 'Exams',
render: () => (
<Section title="Exams"
description="What a learner can be revising for, what counts towards it, and the shape a real paper has.">
<ExamAdmin />
</Section>
) },
{ key: 'people', group: 'The site', icon: '👥', label: 'People',
render: () => <AdminSection section="users" title="People"
description="Everyone with an account, and what each of them may do." /> },