diff --git a/backend/alembic/versions/a7b8c9d0e1f2_exam_groups_and_blueprints.py b/backend/alembic/versions/a7b8c9d0e1f2_exam_groups_and_blueprints.py new file mode 100644 index 0000000..eb204d0 --- /dev/null +++ b/backend/alembic/versions/a7b8c9d0e1f2_exam_groups_and_blueprints.py @@ -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") diff --git a/backend/app/models/exam.py b/backend/app/models/exam.py index 19312ea..2c6a339 100644 --- a/backend/app/models/exam.py +++ b/backend/app/models/exam.py @@ -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) diff --git a/backend/app/routers/exams.py b/backend/app/routers/exams.py index 8cca8ad..af5b21e 100644 --- a/backend/app/routers/exams.py +++ b/backend/app/routers/exams.py @@ -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)} diff --git a/backend/app/services/exam_blueprint.py b/backend/app/services/exam_blueprint.py new file mode 100644 index 0000000..768b785 --- /dev/null +++ b/backend/app/services/exam_blueprint.py @@ -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) diff --git a/backend/app/services/quiz_builder.py b/backend/app/services/quiz_builder.py index e47dc88..6d5fa9d 100644 --- a/backend/app/services/quiz_builder.py +++ b/backend/app/services/quiz_builder.py @@ -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 diff --git a/backend/scripts/abp-2024-outline.json b/backend/scripts/abp-2024-outline.json new file mode 100644 index 0000000..1580341 --- /dev/null +++ b/backend/scripts/abp-2024-outline.json @@ -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" + } + ] + } +] \ No newline at end of file diff --git a/backend/scripts/seed_abp_blueprint.py b/backend/scripts/seed_abp_blueprint.py new file mode 100644 index 0000000..553ee6b --- /dev/null +++ b/backend/scripts/seed_abp_blueprint.py @@ -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()) diff --git a/backend/tests/test_exam_admin.py b/backend/tests/test_exam_admin.py new file mode 100644 index 0000000..e9035c7 --- /dev/null +++ b/backend/tests/test_exam_admin.py @@ -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() diff --git a/backend/tests/test_exam_blueprint.py b/backend/tests/test_exam_blueprint.py new file mode 100644 index 0000000..df98e20 --- /dev/null +++ b/backend/tests/test_exam_blueprint.py @@ -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() diff --git a/backend/tests/test_exams.py b/backend/tests/test_exams.py index 88d7f2c..2a026d1 100644 --- a/backend/tests/test_exams.py +++ b/backend/tests/test_exams.py @@ -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) diff --git a/frontend/src/components/ExamAdmin.css b/frontend/src/components/ExamAdmin.css new file mode 100644 index 0000000..72bff83 --- /dev/null +++ b/frontend/src/components/ExamAdmin.css @@ -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; } +} diff --git a/frontend/src/components/ExamAdmin.jsx b/frontend/src/components/ExamAdmin.jsx new file mode 100644 index 0000000..6f6c3ce --- /dev/null +++ b/frontend/src/components/ExamAdmin.jsx @@ -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
+ + return ( +
+ {error &&

{error}

} + {notice &&

{notice}

} + + {creating ? ( +
+
+ + setDraft(d => ({ ...d, name: e.target.value }))} /> +
+
+ + setDraft(d => ({ ...d, family: e.target.value }))} /> + The picker groups by this. Left blank, it lands in “Other”. +
+
+ + setDraft(d => ({ ...d, slug: e.target.value }))} /> + Fixed once created — other things hold it. +
+
+ + setDraft(d => ({ ...d, description: e.target.value }))} /> +
+
+ Article views + {/* 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 => ( + + ))} +

None chosen means all three.

+
+
+ + +
+
+ ) : ( + + )} + + +
+ ) +} diff --git a/frontend/src/components/ExamAdmin.test.jsx b/frontend/src/components/ExamAdmin.test.jsx new file mode 100644 index 0000000..7a5d5f1 --- /dev/null +++ b/frontend/src/components/ExamAdmin.test.jsx @@ -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() + 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() + 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() + 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() + }) +}) diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index 241f6a8..5314993 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -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: () => }, + { key: 'exams', group: 'The site', icon: '🎓', label: 'Exams', + render: () => ( +
+ +
+ ) }, { key: 'people', group: 'The site', icon: '👥', label: 'People', render: () => },