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:
parent
d1de9589ad
commit
5c4823d9a4
14 changed files with 1980 additions and 12 deletions
|
|
@ -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")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
178
backend/app/services/exam_blueprint.py
Normal file
178
backend/app/services/exam_blueprint.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
554
backend/scripts/abp-2024-outline.json
Normal file
554
backend/scripts/abp-2024-outline.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
143
backend/scripts/seed_abp_blueprint.py
Normal file
143
backend/scripts/seed_abp_blueprint.py
Normal 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())
|
||||
148
backend/tests/test_exam_admin.py
Normal file
148
backend/tests/test_exam_admin.py
Normal 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()
|
||||
135
backend/tests/test_exam_blueprint.py
Normal file
135
backend/tests/test_exam_blueprint.py
Normal 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()
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
82
frontend/src/components/ExamAdmin.css
Normal file
82
frontend/src/components/ExamAdmin.css
Normal 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; }
|
||||
}
|
||||
252
frontend/src/components/ExamAdmin.jsx
Normal file
252
frontend/src/components/ExamAdmin.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
92
frontend/src/components/ExamAdmin.test.jsx
Normal file
92
frontend/src/components/ExamAdmin.test.jsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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." /> },
|
||||
|
|
|
|||
Loading…
Reference in a new issue