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
313 lines
12 KiB
Python
313 lines
12 KiB
Python
"""Exams — the study objective a learner is working towards.
|
|
|
|
An exam sits above systems and disciplines: the same paediatric cardiology
|
|
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, Field
|
|
from sqlalchemy import func
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.database import get_db
|
|
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.services import exam_blueprint
|
|
from app.utils.auth import get_current_user, require_admin, require_moderator
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# The three readings an article can offer. An objective may show a subset.
|
|
ARTICLE_VIEWS = ("short", "long", "clinical")
|
|
|
|
|
|
class ExamWrite(BaseModel):
|
|
name: str
|
|
slug: str
|
|
sort_order: int = 100
|
|
is_active: int = 1
|
|
family: str | None = None
|
|
description: str | None = None
|
|
article_views: list[str] | None = None
|
|
|
|
|
|
def views_for(exam: Exam | None) -> list[str]:
|
|
"""Which article views this objective shows. No objective means all of them."""
|
|
if exam is None or not exam.article_views:
|
|
return list(ARTICLE_VIEWS)
|
|
chosen = [v for v in ARTICLE_VIEWS if v in exam.article_views]
|
|
# An objective that shows nothing would leave every article blank, which is
|
|
# a configuration mistake rather than a preference worth honouring.
|
|
return chosen or list(ARTICLE_VIEWS)
|
|
|
|
|
|
@router.get("/")
|
|
def list_exams(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
|
"""Every selectable exam, with how many bank questions it covers."""
|
|
counts = dict(
|
|
db.query(QuestionExamLink.exam_id, func.count(QuestionExamLink.question_id))
|
|
.group_by(QuestionExamLink.exam_id).all()
|
|
)
|
|
exams = db.query(Exam).filter(Exam.is_active == 1).order_by(Exam.sort_order, Exam.name).all()
|
|
active = db.get(Exam, current_user.active_exam_id) if current_user.active_exam_id else None
|
|
return {
|
|
"active_exam_id": current_user.active_exam_id,
|
|
"active_exam_name": active.name if active else None,
|
|
# What the current objective actually changes, so the interface can say so
|
|
# rather than leaving the learner to guess whether it did anything.
|
|
"article_views": views_for(active),
|
|
"exams": [{"id": e.id, "slug": e.slug, "name": e.name,
|
|
"family": e.family or "Other", "description": e.description,
|
|
"article_views": views_for(e),
|
|
"question_count": counts.get(e.id, 0)} for e in exams],
|
|
}
|
|
|
|
|
|
class ActiveExam(BaseModel):
|
|
exam_id: int | None = None
|
|
|
|
|
|
@router.put("/active")
|
|
def set_active_exam(
|
|
data: ActiveExam,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Switch which exam the learner is studying for. Persisted on the user."""
|
|
if data.exam_id is not None:
|
|
exam = db.get(Exam, data.exam_id)
|
|
if not exam or not exam.is_active:
|
|
raise HTTPException(404, "Exam not found")
|
|
current_user.active_exam_id = data.exam_id
|
|
db.commit()
|
|
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_admin),
|
|
):
|
|
"""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=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 _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)}
|