feat: real organ systems, symptoms grouped by system, and admin taxonomy CRUD

Systems were never systems
The 27 top-level rows were disciplines and care settings — Cardiology,
Emergency Medicine, Neonatology, and a stray condition (Sepsis) — not organ
systems. Cardiology is a discipline; Cardiovascular System is a system. So the
facet was mislabelled, and there was no organ-system axis at all.

Both fixes, as asked:
  * that tree is now the "Topics" facet, which is what it always was;
  * "Systems" is a new flat axis of 16 organ systems, matching how AMBOSS keeps
    Systems flat while nesting Disciplines and Symptoms.

Tags can nest (migration e3f4a5b6c7d8)
`question_tags` gains parent_id and sort_order. A tag may sit under one of the
same kind (Surgery > Hand surgery) or under a system, which is how symptoms are
grouped by where they present. 726 symptoms are now filed under the system they
appear in; the remaining 3,536 stay top-level rather than being forced into an
approximate bucket. A false positive the dry run caught: "vision" was matching
"Health Supervision" — the same trap as erythema/erythematosus earlier, fixed
with a word boundary.

Admin can grow the taxonomy without a migration
POST /tags creates a top-level entry or a child; PATCH renames, reorders and
reparents, refusing a cycle; DELETE reparents children to the deleted tag's
parent rather than orphaning them, and can move its questions elsewhere;
POST /tags/{id}/questions attaches questions. Everything appears in every picker
immediately, because they all read the same endpoint.

Article sections were indexed but empty — `_rebuild_section_index` only runs on
save, so articles written before it existed had no rows. Backfilled: 10 articles,
28 sections, now embedded and searchable. Section-scoped question links already
worked (7 of 34 links name a section).

Tests: 10 new backend covering the tree shape, adding top-level and child
entries, kind rules, duplicate refusal, cycle refusal, rename/reparent, question
attachment, delete-reparents-children, delete-with-move, and the moderator gate.
141 backend, 136 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017acfNLsJpnkvH3sCZSjMJM
This commit is contained in:
Daniel 2026-09-10 10:44:39 +02:00
parent 04310c8980
commit d509d3db52
10 changed files with 575 additions and 26 deletions

View file

@ -0,0 +1,61 @@
"""Let tags nest, and add organ systems as their own type.
AMBOSS keeps Systems flat (organ systems) and nests Disciplines and Symptoms
("Head and neck > Eye > Blindness"). Our tag table was flat for all three, so
symptoms could not be grouped by body region and there was no organ-system axis
at all what the UI called "Systems" was really the discipline tree.
Revision ID: e3f4a5b6c7d8
Revises: d2e3f4a5b6c7
"""
from alembic import op
revision = "e3f4a5b6c7d8"
down_revision = "d2e3f4a5b6c7"
branch_labels = None
depends_on = None
# The organ systems, plus the non-anatomical buckets an exam bank needs.
SYSTEMS = [
"Cardiovascular System",
"Respiratory System",
"Gastrointestinal System",
"Nervous System & Special Senses",
"Endocrine System",
"Musculoskeletal System",
"Skin & Subcutaneous Tissue",
"Blood & Lymphoreticular System",
"Immune System",
"Renal & Urinary System",
"Reproductive System",
"Behavioral Health",
"Human Development",
"Multisystem Processes & Disorders",
"Biostatistics & Epidemiology",
"Social Sciences & Ethics",
]
def upgrade():
op.execute("""
ALTER TABLE question_tags ADD COLUMN IF NOT EXISTS parent_id INTEGER
REFERENCES question_tags(id) ON DELETE SET NULL
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_question_tags_parent ON question_tags(parent_id)")
op.execute("ALTER TABLE question_tags ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 100")
for index, name in enumerate(SYSTEMS):
op.execute(f"""
INSERT INTO question_tags (name, type, sort_order)
SELECT '{name.replace("'", "''")}', 'system', {index * 10}
WHERE NOT EXISTS (
SELECT 1 FROM question_tags WHERE type = 'system' AND lower(name) = lower('{name.replace("'", "''")}')
)
""")
def downgrade():
op.execute("DELETE FROM question_tags WHERE type = 'system'")
op.execute("ALTER TABLE question_tags DROP COLUMN IF EXISTS sort_order")
op.execute("DROP INDEX IF EXISTS ix_question_tags_parent")
op.execute("ALTER TABLE question_tags DROP COLUMN IF EXISTS parent_id")

View file

@ -4,7 +4,10 @@ Tables are created via raw SQL in setup_pgvector() to avoid race conditions with
import logging
import uuid
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.orm import Session
@ -24,22 +27,32 @@ def get_tags(db: Session = Depends(get_db), current_user: User = Depends(get_cur
# cannot match anything they are studying.
exam_id = getattr(current_user, "active_exam_id", None)
rows = db.execute(text("""
SELECT t.id, t.name, t.type, COUNT(DISTINCT tl.question_id) AS count
SELECT t.id, t.name, t.type, t.parent_id, t.sort_order,
COUNT(DISTINCT tl.question_id) AS count
FROM question_tags t
LEFT JOIN question_tag_links tl ON tl.tag_id = t.id
LEFT JOIN question_exam_links el
ON el.question_id = tl.question_id AND (:exam_id IS NULL OR el.exam_id = :exam_id)
WHERE (t.exam_id IS NULL OR :exam_id IS NULL OR t.exam_id = :exam_id)
AND (:exam_id IS NULL OR tl.question_id IS NULL OR el.question_id IS NOT NULL)
GROUP BY t.id, t.name, t.type
ORDER BY count DESC, t.name
GROUP BY t.id, t.name, t.type, t.parent_id, t.sort_order
ORDER BY t.sort_order, count DESC, t.name
"""), {"exam_id": exam_id}).fetchall()
result = {"subjects": [], "diseases": [], "keywords": []}
result = {"subjects": [], "diseases": [], "keywords": [], "systems": []}
mapping = {"subject": "subjects", "disease": "diseases",
"keyword": "keywords", "system": "systems"}
# A parent with no direct questions still has to appear, or its children
# become unreachable in a tree picker.
has_children = {r.parent_id for r in rows if r.parent_id}
for r in rows:
key = {"subject": "subjects", "disease": "diseases", "keyword": "keywords"}.get(r.type)
if key and (r.count > 0 or not exam_id):
result[key].append({"id": r.id, "name": r.name, "count": r.count})
key = mapping.get(r.type)
if not key:
continue
if exam_id and r.count == 0 and r.id not in has_children:
continue
result[key].append({"id": r.id, "name": r.name, "count": r.count,
"parent_id": r.parent_id})
return result
@ -73,3 +86,139 @@ def get_classification_status(
error = r.get(f"classify:error:{job_id}")
return {"job_id": job_id, "status": status, "steps": steps, "error": error}
class TagWrite(BaseModel):
name: str = Field(min_length=1, max_length=200)
type: Literal["subject", "disease", "keyword", "system"]
parent_id: int | None = None
sort_order: int = 100
def _descendants(db: Session, tag_id: int) -> set[int]:
"""Every tag beneath this one, so a cycle can be refused."""
children: dict[int, list[int]] = {}
for tid, parent in db.execute(text(
"SELECT id, parent_id FROM question_tags WHERE parent_id IS NOT NULL")).fetchall():
children.setdefault(parent, []).append(tid)
seen, stack = set(), [tag_id]
while stack:
for child in children.get(stack.pop(), ()):
if child not in seen:
seen.add(child)
stack.append(child)
return seen
def _validate_parent(db: Session, tag_id: int | None, parent_id: int | None, tag_type: str) -> None:
if parent_id is None:
return
parent = db.execute(text("SELECT id, type FROM question_tags WHERE id = :i"),
{"i": parent_id}).first()
if not parent:
raise HTTPException(404, "Parent tag not found")
# Same kind nests (Surgery > Hand surgery), and anything may sit under a
# system, which is how symptoms are grouped by where they present.
if parent.type != tag_type and parent.type != "system":
raise HTTPException(400, "A tag can only sit under one of the same kind, or under a system")
if tag_id is not None and (parent_id == tag_id or parent_id in _descendants(db, tag_id)):
raise HTTPException(400, "A tag cannot be its own parent or a descendant's child")
@router.post("/", status_code=201)
def create_tag(data: TagWrite, db: Session = Depends(get_db),
current_user: User = Depends(require_moderator)):
"""Add a top-level entry or a child. New tags appear in every picker at once."""
_validate_parent(db, None, data.parent_id, data.type)
clash = db.execute(text("""
SELECT id FROM question_tags
WHERE type = :t AND lower(name) = lower(:n)
AND COALESCE(parent_id, 0) = COALESCE(:p, 0)
"""), {"t": data.type, "n": data.name.strip(), "p": data.parent_id}).first()
if clash:
raise HTTPException(409, "That name already exists in the same place")
tag_id = db.execute(text("""
INSERT INTO question_tags (name, type, parent_id, sort_order)
VALUES (:n, :t, :p, :s) RETURNING id
"""), {"n": data.name.strip(), "t": data.type,
"p": data.parent_id, "s": data.sort_order}).scalar()
db.commit()
return {"id": tag_id, "name": data.name.strip(), "type": data.type,
"parent_id": data.parent_id, "count": 0}
class TagUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=200)
parent_id: int | None = None
sort_order: int | None = None
@router.patch("/{tag_id}")
def update_tag(tag_id: int, data: TagUpdate, db: Session = Depends(get_db),
current_user: User = Depends(require_moderator)):
"""Rename, reorder, or move a tag under a different parent."""
tag = db.execute(text("SELECT id, type FROM question_tags WHERE id = :i"),
{"i": tag_id}).first()
if not tag:
raise HTTPException(404, "Tag not found")
values = data.model_dump(exclude_unset=True)
if "parent_id" in values:
_validate_parent(db, tag_id, values["parent_id"], tag.type)
db.execute(text("UPDATE question_tags SET parent_id = :p WHERE id = :i"),
{"p": values["parent_id"], "i": tag_id})
if values.get("name"):
db.execute(text("UPDATE question_tags SET name = :n WHERE id = :i"),
{"n": values["name"].strip(), "i": tag_id})
if values.get("sort_order") is not None:
db.execute(text("UPDATE question_tags SET sort_order = :s WHERE id = :i"),
{"s": values["sort_order"], "i": tag_id})
db.commit()
return {"id": tag_id}
@router.delete("/{tag_id}", status_code=204)
def delete_tag(tag_id: int, move_to: int | None = None, db: Session = Depends(get_db),
current_user: User = Depends(require_moderator)):
"""Delete a tag, optionally moving its questions and children onto another."""
tag = db.execute(text("SELECT id, parent_id FROM question_tags WHERE id = :i"),
{"i": tag_id}).first()
if not tag:
raise HTTPException(404, "Tag not found")
if move_to is not None:
if move_to == tag_id or not db.execute(text(
"SELECT id FROM question_tags WHERE id = :i"), {"i": move_to}).first():
raise HTTPException(400, "Invalid destination tag")
# Skip links that would duplicate an existing pair.
db.execute(text("""
UPDATE question_tag_links SET tag_id = :d
WHERE tag_id = :s AND question_id NOT IN (
SELECT question_id FROM question_tag_links WHERE tag_id = :d)
"""), {"d": move_to, "s": tag_id})
# Children rise to this tag's parent rather than being orphaned.
db.execute(text("UPDATE question_tags SET parent_id = :p WHERE parent_id = :i"),
{"p": tag.parent_id, "i": tag_id})
db.execute(text("DELETE FROM question_tag_links WHERE tag_id = :i"), {"i": tag_id})
db.execute(text("DELETE FROM question_tags WHERE id = :i"), {"i": tag_id})
db.commit()
class TagQuestions(BaseModel):
question_ids: list[int] = Field(min_length=1, max_length=500)
@router.post("/{tag_id}/questions")
def add_questions_to_tag(tag_id: int, data: TagQuestions, db: Session = Depends(get_db),
current_user: User = Depends(require_moderator)):
"""Attach questions to a tag; they show in every picker using it immediately."""
if not db.execute(text("SELECT id FROM question_tags WHERE id = :i"), {"i": tag_id}).first():
raise HTTPException(404, "Tag not found")
added = 0
for question_id in dict.fromkeys(data.question_ids):
result = db.execute(text("""
INSERT INTO question_tag_links (question_id, tag_id)
SELECT :q, :t WHERE EXISTS (SELECT 1 FROM questions WHERE id = :q)
AND NOT EXISTS (
SELECT 1 FROM question_tag_links WHERE question_id = :q AND tag_id = :t)
"""), {"q": question_id, "t": tag_id})
added += result.rowcount or 0
db.commit()
return {"tag_id": tag_id, "added": added}

View file

@ -0,0 +1,37 @@
"""Project existing article sections into the section index.
`_rebuild_section_index` runs when an article is saved, so articles written
before that existed have no rows the index was empty and section-level
retrieval had nothing to search.
docker compose exec backend python -m scripts.backfill_section_index
docker compose exec backend python -m scripts.backfill_section_index --apply
"""
import sys
from app.database import SessionLocal
from app.models.article import Article
from app.routers.articles import _rebuild_section_index
def main():
apply_changes = "--apply" in sys.argv
db = SessionLocal()
try:
articles = db.query(Article).all()
sections = sum(len(a.sections or []) for a in articles)
print(f" articles : {len(articles)}")
print(f" sections : {sections}")
if apply_changes:
for article in articles:
_rebuild_section_index(db, article)
db.commit()
print(" indexed and embedded.")
else:
print("\n Re-run with --apply to build the index.")
finally:
db.close()
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,125 @@
"""Group symptom tags under the organ system they present in.
Symptoms were a flat list of ~4,300 keywords, so the picker was an alphabetical
wall. AMBOSS groups them by body region "Head and neck > Eye > Blindness" and
that is what makes the list navigable.
Only the symptoms that match a system's pattern are moved. Everything else stays
top-level rather than being forced into an approximate bucket, and nothing is
renamed, merged or deleted: this only sets a parent.
docker compose exec backend python -m scripts.nest_symptoms_by_system
docker compose exec backend python -m scripts.nest_symptoms_by_system --apply
"""
import re
import sys
from collections import defaultdict
from sqlalchemy import text as sa_text
from app.database import SessionLocal
# Only worth a branch if several symptoms land in it.
MIN_CHILDREN = 3
# Organ system -> what a symptom in it looks like. Ordered: the first match wins,
# so put the specific patterns above the general ones.
SYSTEM_PATTERNS = [
("Nervous System & Special Senses",
r"seizure|headache|migraine|ataxia|paralysis|palsy|neuropath|hypotoni|hypertoni|"
r"tremor|dizz|vertigo|syncope|coma|conscious|meningism|blind|\bvision\b|visual|"
r"strabismus|nystagmus|deaf|hearing|tinnitus|aphasia|dysarthr|weakness|numbness"),
("Cardiovascular System",
r"murmur|palpitation|chest pain|cyanosis|hypertens|hypotens|tachycardi|bradycardi|"
r"edema|oedema|shock|syncope|pulse|perfusion"),
("Respiratory System",
r"cough|wheez|stridor|dyspnea|dyspnoea|tachypnea|apnea|apnoea|respiratory distress|"
r"hemoptysis|haemoptysis|hypox|retraction|grunting"),
("Gastrointestinal System",
r"vomit|diarrhea|diarrhoea|constipation|abdominal pain|abdominal distention|"
r"abdominal distension|jaundice|hematemesis|melena|dysphagia|reflux|colic|"
r"hepatomegal|splenomegal|ascites|encopresis"),
("Renal & Urinary System",
r"hematuria|haematuria|proteinuria|dysuria|oliguria|polyuria|enuresis|"
r"urinary frequency|urinary retention|flank pain"),
("Musculoskeletal System",
r"limp|joint pain|arthralgia|arthritis|back pain|scoliosis|fracture|"
r"limb pain|gait|swelling of the joint|muscle pain|myalgia"),
("Skin & Subcutaneous Tissue",
r"rash|pruritus|itch|urticaria|petechia|purpura|blister|vesicle|pustule|"
r"alopecia|hair loss|pallor|erythema|lesion"),
("Blood & Lymphoreticular System",
r"anemia|anaemia|bleeding|bruis|lymphadenopath|pallor|petechia|thrombocytopen"),
("Endocrine System",
r"polydipsia|polyuria|weight loss|weight gain|obesity|short stature|tall stature|"
r"precocious|delayed puberty|goiter|goitre|hypoglycem|hyperglycem"),
("Behavioral Health",
r"anxiety|depress|irritab|hyperactiv|inattent|aggress|self.harm|suicid|"
r"insomnia|sleep disturbance|tantrum|behaviou?r"),
("Immune System",
r"allerg|anaphylax|recurrent infection|immunodefic|angioedema"),
("Human Development",
r"developmental delay|failure to thrive|growth failure|milestone|regression|"
r"feeding difficult|poor feeding"),
("Multisystem Processes & Disorders",
r"\bfever\b|fatigue|lethargy|malaise|sepsis|weight loss|dehydration|"
r"failure to thrive|night sweats"),
]
def main():
apply_changes = "--apply" in sys.argv
db = SessionLocal()
try:
systems = {row[1].lower(): row[0] for row in db.execute(sa_text(
"SELECT id, name FROM question_tags WHERE type = 'system'")).fetchall()}
if not systems:
print(" No 'system' tags yet; run the migration first.")
return 1
symptoms = db.execute(sa_text("""
SELECT t.id, t.name, COUNT(l.question_id) AS uses
FROM question_tags t
LEFT JOIN question_tag_links l ON l.tag_id = t.id
WHERE t.type = 'keyword' AND t.parent_id IS NULL
GROUP BY t.id, t.name
ORDER BY uses DESC, t.name
""")).fetchall()
print(f" ungrouped symptoms: {len(symptoms)}")
compiled = [(name, re.compile(pattern, re.I)) for name, pattern in SYSTEM_PATTERNS]
planned: dict[str, list[tuple[int, str, int]]] = defaultdict(list)
for tag_id, name, uses in symptoms:
for system_name, matcher in compiled:
if system_name.lower() in systems and matcher.search(name):
planned[system_name].append((tag_id, name, uses))
break
# A branch holding one or two entries is noise, not navigation.
planned = {k: v for k, v in planned.items() if len(v) >= MIN_CHILDREN}
if apply_changes:
for system_name, members in planned.items():
parent = systems[system_name.lower()]
for tag_id, _name, _uses in members:
db.execute(sa_text("UPDATE question_tags SET parent_id = :p WHERE id = :i"),
{"p": parent, "i": tag_id})
db.commit()
moved = sum(len(v) for v in planned.values())
print("APPLIED" if apply_changes else "DRY RUN")
print(f" grouped under a system : {moved}")
print(f" left top-level : {len(symptoms) - moved}\n")
for system_name in sorted(planned, key=lambda k: -len(planned[k])):
members = planned[system_name]
shown = ", ".join(name for _i, name, _u in members[:4])
more = f" … +{len(members) - 4}" if len(members) > 4 else ""
print(f" {system_name} ({len(members)}): {shown}{more}")
if not apply_changes:
print("\n Re-run with --apply to group them.")
finally:
db.close()
if __name__ == "__main__":
sys.exit(main())

View file

@ -25,7 +25,8 @@ from app.utils.auth import get_current_user
TAG_DDL = """
CREATE TABLE question_tags (
id INTEGER PRIMARY KEY, name VARCHAR(200) NOT NULL,
type VARCHAR(50) NOT NULL, exam_id INTEGER, created_at TIMESTAMP
type VARCHAR(50) NOT NULL, exam_id INTEGER, parent_id INTEGER,
sort_order INTEGER NOT NULL DEFAULT 100, created_at TIMESTAMP
);
CREATE TABLE question_tag_links (
id INTEGER PRIMARY KEY, question_id INTEGER NOT NULL, tag_id INTEGER NOT NULL

View file

@ -23,7 +23,7 @@ from app.routers import media
from app.utils.auth import get_current_user
TAG_DDL = """
CREATE TABLE question_tags (id INTEGER PRIMARY KEY, name VARCHAR(200), type VARCHAR(50), exam_id INTEGER, created_at TIMESTAMP);
CREATE TABLE question_tags (id INTEGER PRIMARY KEY, name VARCHAR(200), type VARCHAR(50), exam_id INTEGER, parent_id INTEGER, sort_order INTEGER DEFAULT 100, created_at TIMESTAMP);
"""

View file

@ -0,0 +1,147 @@
"""Tags nest, and a moderator can grow the taxonomy without a migration.
Run: DATABASE_URL=sqlite:///:memory: PYTHONPATH=backend python -m unittest discover -s backend/tests
"""
import os
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
import unittest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, text
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.models.course import Course # noqa — Quiz.course_id FK needs the table in metadata.
from app.models.media import MediaAsset # noqa — health report walks every embeddable table.
from app.models.question import Question
from app.models.user import User
from app.routers import tags
from app.utils.auth import get_current_user
DDL = """
CREATE TABLE question_tags (
id INTEGER PRIMARY KEY, name VARCHAR(200) NOT NULL, type VARCHAR(50) NOT NULL,
exam_id INTEGER, parent_id INTEGER, sort_order INTEGER NOT NULL DEFAULT 100,
created_at TIMESTAMP
);
CREATE TABLE question_tag_links (id INTEGER PRIMARY KEY, question_id INTEGER, tag_id INTEGER);
"""
class TagHierarchyTests(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)
for statement in DDL.strip().split(";"):
if statement.strip():
self.db.execute(text(statement))
self.mod = User(id=1, name="Mod", email="mod@example.test", hashed_password="unused", role="moderator")
self.learner = User(id=2, name="Learner", email="l@example.test", hashed_password="unused")
self.db.add_all([self.mod, self.learner])
for qid in (1, 2, 3):
self.db.add(Question(id=qid, user_id=1, is_shared=1, question_text=f"Q{qid}",
question_type="mcq", options=["a", "b"], correct_answer="a"))
self.db.execute(text("""
INSERT INTO question_tags (id, name, type, parent_id, sort_order) VALUES
(10, 'Nervous System & Special Senses', 'system', NULL, 0),
(11, 'Seizure', 'keyword', 10, 100),
(12, 'Headache', 'keyword', 10, 100),
(13, 'Cardiology', 'subject', NULL, 100)
"""))
self.db.execute(text("INSERT INTO question_tag_links (question_id, tag_id) VALUES (1, 11), (2, 11)"))
self.db.commit()
self.user = self.mod
app = FastAPI()
app.include_router(tags.router, prefix="/tags")
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 test_the_tree_is_returned_with_parents(self):
body = self.client.get("/tags").json()
systems = {t["name"]: t for t in body["systems"]}
self.assertIn("Nervous System & Special Senses", systems)
keywords = {t["name"]: t["parent_id"] for t in body["keywords"]}
# Symptoms hang off the system they present in.
self.assertEqual(keywords["Seizure"], 10)
self.assertEqual(keywords["Headache"], 10)
def test_a_moderator_can_add_a_top_level_entry_and_a_child(self):
top = self.client.post("/tags/", json={"name": "Respiratory System", "type": "system"})
self.assertEqual(top.status_code, 201, top.text)
parent_id = top.json()["id"]
child = self.client.post("/tags/", json={"name": "Wheeze", "type": "keyword",
"parent_id": parent_id})
self.assertEqual(child.status_code, 201, child.text)
# It shows up immediately, which is what every picker reads.
body = self.client.get("/tags").json()
self.assertIn("Wheeze", [t["name"] for t in body["keywords"]])
def test_a_child_must_match_its_parents_kind(self):
response = self.client.post("/tags/", json={"name": "Nonsense", "type": "keyword",
"parent_id": 13})
self.assertEqual(response.status_code, 400)
def test_duplicate_names_in_the_same_place_are_refused(self):
self.assertEqual(self.client.post("/tags/", json={
"name": "Seizure", "type": "keyword", "parent_id": 10}).status_code, 409)
# The same name under a different parent is fine.
self.assertEqual(self.client.post("/tags/", json={
"name": "Seizure", "type": "keyword"}).status_code, 201)
def test_a_tag_cannot_become_its_own_descendants_child(self):
self.assertEqual(self.client.patch("/tags/10", json={"parent_id": 11}).status_code, 400)
self.assertEqual(self.client.patch("/tags/10", json={"parent_id": 10}).status_code, 400)
def test_renaming_and_reparenting(self):
self.assertEqual(self.client.patch("/tags/12", json={"name": "Head pain"}).status_code, 200)
self.assertEqual(self.db.execute(text(
"SELECT name FROM question_tags WHERE id = 12")).scalar(), "Head pain")
self.client.patch("/tags/12", json={"parent_id": None})
self.assertIsNone(self.db.execute(text(
"SELECT parent_id FROM question_tags WHERE id = 12")).scalar())
def test_questions_attached_to_a_tag_appear_in_its_count(self):
response = self.client.post("/tags/12/questions", json={"question_ids": [1, 2, 3]})
self.assertEqual(response.json()["added"], 3)
counts = {t["name"]: t["count"] for t in self.client.get("/tags").json()["keywords"]}
self.assertEqual(counts["Headache"], 3)
# Adding the same ones again is a no-op rather than a duplicate.
self.assertEqual(self.client.post("/tags/12/questions",
json={"question_ids": [1, 2]}).json()["added"], 0)
def test_deleting_reparents_children_rather_than_orphaning_them(self):
self.client.delete("/tags/10")
# Seizure and Headache were under it; they rise to top level, not nowhere.
rows = self.db.execute(text(
"SELECT parent_id FROM question_tags WHERE id IN (11, 12)")).fetchall()
self.assertEqual([r[0] for r in rows], [None, None])
def test_deleting_can_move_its_questions_onto_another_tag(self):
self.client.delete("/tags/11", params={"move_to": 12})
counts = {t["name"]: t["count"] for t in self.client.get("/tags").json()["keywords"]}
self.assertEqual(counts["Headache"], 2)
def test_editing_the_taxonomy_is_moderator_only(self):
self.user = self.learner
self.assertEqual(self.client.post("/tags/", json={"name": "X", "type": "keyword"}).status_code, 403)
self.assertEqual(self.client.patch("/tags/11", json={"name": "X"}).status_code, 403)
self.assertEqual(self.client.delete("/tags/11").status_code, 403)
self.assertEqual(self.client.post("/tags/11/questions",
json={"question_ids": [1]}).status_code, 403)
if __name__ == "__main__":
unittest.main()

View file

@ -130,9 +130,11 @@ export default function CustomQuizPage() {
const nameById = useMemo(() => Object.fromEntries(categories.map(c => [c.id, c.name])), [categories])
const subjectTags = tags.subjects || []
const keywordTags = tags.keywords || []
const tagName = (id) => [...subjectTags, ...keywordTags].find(t => t.id === id)?.name
const tagName = (id) => [...subjectTags, ...keywordTags, ...(tags.systems || [])].find(t => t.id === id)?.name
const systemTags = tags.systems || []
const systems = summarise(categoryIds.map(id => nameById[id]).filter(Boolean))
const organSystems = summarise(tagIds.map(tagName).filter(n => n && systemTags.some(t => t.name === n)))
const disciplines = summarise(tagIds.map(tagName).filter(n => n && subjectTags.some(t => t.name === n)))
const symptoms = summarise(tagIds.map(tagName).filter(n => n && keywordTags.some(t => t.name === n)))
const articleSummary = summarise(articleIds.map(id => articles.find(a => a.id === id)?.title).filter(Boolean))
@ -260,7 +262,8 @@ export default function CustomQuizPage() {
<span className="facet-row-label">Exams</span>
<span className="facet-row-summary"><span className="facet-chip">Pediatrics Boards</span></span>
</div>
<FacetRow label="Systems" {...systems} onOpen={() => setOpenFacet('systems')} />
<FacetRow label="Systems" {...organSystems} onOpen={() => setOpenFacet('organ-systems')} />
<FacetRow label="Topics" {...systems} onOpen={() => setOpenFacet('systems')} />
<FacetRow label="Disciplines" {...disciplines} onOpen={() => setOpenFacet('disciplines')} />
<FacetRow label="Symptoms" {...symptoms} onOpen={() => setOpenFacet('symptoms')} />
<FacetRow label="Articles" {...articleSummary} onOpen={() => setOpenFacet('articles')} />
@ -372,7 +375,24 @@ export default function CustomQuizPage() {
{error && <p className="custom-test-error" role="alert">{error}</p>}
</form>
{/* ── Facet pickers ────────────────────────────────────────── */}
<FacetPicker title="Systems" open={openFacet === 'systems'} onClose={() => setOpenFacet(null)}
<FacetPicker title="Systems" open={openFacet === 'organ-systems'} onClose={() => setOpenFacet(null)}
onReset={() => setTagIds(ids => ids.filter(id => !systemTags.some(t => t.id === id)))}
helper="Body systems. Flat by nature — there are only so many.">
{query => {
const shown = systemTags.filter(t => !query || t.name.toLowerCase().includes(query))
if (!shown.length) return <p className="facet-panel-empty">Nothing matches that search.</p>
return shown.map(tag => (
<label key={tag.id}>
<input type="checkbox" checked={tagIds.includes(tag.id)}
onChange={e => toggleTag(tag.id, e.target.checked)} />
{tag.name}
<span className="facet-panel-count">{tag.count}</span>
</label>
))
}}
</FacetPicker>
<FacetPicker title="Topics" open={openFacet === 'systems'} onClose={() => setOpenFacet(null)}
onReset={() => setCategoryIds([])}
helper="Open a system to see what is under it. Choosing one includes everything beneath.">
{query => (

View file

@ -42,7 +42,7 @@ describe('CustomQuizPage', () => {
expect(screen.queryByLabelText(/Time limit/)).not.toBeInTheDocument()
// Systems and Status are chosen inside their facet pickers, not inline.
await userEvent.click(screen.getByRole('button', { name: /^Systems/ }))
await userEvent.click(screen.getByRole('button', { name: /^Topics/ }))
await userEvent.click(await screen.findByRole('checkbox', { name: /Pediatrics/ }))
await userEvent.click(screen.getByRole('checkbox', { name: /Neonatal/ }))
await userEvent.click(screen.getByRole('button', { name: 'Done' }))
@ -103,32 +103,32 @@ describe('CustomQuizPage', () => {
it('summarises each facet as All, a name, or a name with +N', async () => {
renderBuilder()
const systems = await screen.findByRole('button', { name: /^Systems/ })
const systems = await screen.findByRole('button', { name: /^Topics/ })
expect(systems).toHaveTextContent('All')
await userEvent.click(systems)
await userEvent.click(await screen.findByRole('checkbox', { name: /Pediatrics/ }))
await userEvent.click(screen.getByRole('button', { name: 'Done' }))
expect(screen.getByRole('button', { name: /^Systems/ })).toHaveTextContent('Pediatrics')
expect(screen.getByRole('button', { name: /^Topics/ })).toHaveTextContent('Pediatrics')
expect(screen.queryByText('+1')).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: /^Systems/ }))
await userEvent.click(screen.getByRole('button', { name: /^Topics/ }))
await userEvent.click(await screen.findByRole('checkbox', { name: /Neonatal/ }))
await userEvent.click(screen.getByRole('button', { name: 'Done' }))
expect(screen.getByRole('button', { name: /^Systems/ })).toHaveTextContent('+1')
expect(screen.getByRole('button', { name: /^Topics/ })).toHaveTextContent('+1')
})
it('closes a picker without losing the selection and resets it on demand', async () => {
renderBuilder()
await userEvent.click(await screen.findByRole('button', { name: /^Systems/ }))
await userEvent.click(await screen.findByRole('button', { name: /^Topics/ }))
await userEvent.click(await screen.findByRole('checkbox', { name: /Pediatrics/ }))
await userEvent.click(screen.getByRole('button', { name: 'Close Systems' }))
expect(screen.getByRole('button', { name: /^Systems/ })).toHaveTextContent('Pediatrics')
await userEvent.click(screen.getByRole('button', { name: 'Close Topics' }))
expect(screen.getByRole('button', { name: /^Topics/ })).toHaveTextContent('Pediatrics')
await userEvent.click(screen.getByRole('button', { name: /^Systems/ }))
await userEvent.click(screen.getByRole('button', { name: /^Topics/ }))
await userEvent.click(screen.getByRole('button', { name: 'Reset' }))
await userEvent.click(screen.getByRole('button', { name: 'Done' }))
expect(screen.getByRole('button', { name: /^Systems/ })).toHaveTextContent('All')
expect(screen.getByRole('button', { name: /^Topics/ })).toHaveTextContent('All')
})
it('searches across facets and toggles a hit in place', async () => {
@ -153,11 +153,11 @@ describe('CustomQuizPage', () => {
it('resets every topic facet at once', async () => {
renderBuilder()
await userEvent.click(await screen.findByRole('button', { name: /^Systems/ }))
await userEvent.click(await screen.findByRole('button', { name: /^Topics/ }))
await userEvent.click(await screen.findByRole('checkbox', { name: /Pediatrics/ }))
await userEvent.click(screen.getByRole('button', { name: 'Done' }))
await userEvent.click(screen.getByRole('button', { name: 'Reset all topics' }))
expect(screen.getByRole('button', { name: /^Systems/ })).toHaveTextContent('All')
expect(screen.getByRole('button', { name: /^Topics/ })).toHaveTextContent('All')
})
it('keeps the count in the criteria card and mode plus Start in a sticky bar', async () => {

View file

@ -366,7 +366,9 @@ export default function QuestionBankPage() {
const nameOf = (list, id) => list.find(t => t.id === id)?.name
const selectedIn = (list) => selectedTagIds.map(id => nameOf(list, id)).filter(Boolean)
const systemTags = tags.systems || []
const systemsFacet = summarise(filterCatIds.map(id => categories.find(c => c.id === id)?.name).filter(Boolean))
const organSystemsFacet = summarise(selectedIn(systemTags))
const disciplinesFacet = summarise(selectedIn(subjectTags))
const diseasesFacet = summarise(selectedIn(diseaseTags))
const symptomsFacet = summarise(selectedIn(keywordTags))
@ -728,7 +730,8 @@ export default function QuestionBankPage() {
onOpen={() => setOpenFacet('status')} />
<FacetRow label="Difficulty" summary={DIFFICULTY_LABEL[difficulty]} extra={0}
chip={!!difficulty} onOpen={() => setOpenFacet('difficulty')} />
<FacetRow label="Systems" {...systemsFacet} onOpen={() => setOpenFacet('systems')} />
<FacetRow label="Systems" {...organSystemsFacet} onOpen={() => setOpenFacet('organ-systems')} />
<FacetRow label="Topics" {...systemsFacet} onOpen={() => setOpenFacet('systems')} />
<FacetRow label="Disciplines" {...disciplinesFacet} onOpen={() => setOpenFacet('disciplines')} />
<FacetRow label="Diseases" {...diseasesFacet} onOpen={() => setOpenFacet('diseases')} />
<FacetRow label="Symptoms" {...symptomsFacet} onOpen={() => setOpenFacet('symptoms')} />
@ -907,7 +910,13 @@ export default function QuestionBankPage() {
))}
</FacetPicker>
<FacetPicker title="Systems" open={openFacet === 'systems'} onClose={() => setOpenFacet(null)}
<FacetPicker title="Systems" open={openFacet === 'organ-systems'} onClose={() => setOpenFacet(null)}
onReset={() => setSelectedTagIds(ids => ids.filter(id => !systemTags.some(t => t.id === id)))}
helper="Body systems. Flat by nature — there are only so many.">
{query => tagChecklist(systemTags, query)}
</FacetPicker>
<FacetPicker title="Topics" open={openFacet === 'systems'} onClose={() => setOpenFacet(null)}
onReset={() => setFilterCatIds([])}
helper="Open a system to see what is under it. Choosing one includes everything beneath.">
{query => (