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
148 lines
7.3 KiB
Python
148 lines
7.3 KiB
Python
"""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()
|