From ebcc11ae12ec7df483d127e5d08702d51d9285d2 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 05:06:58 +0200 Subject: [PATCH] feat: retire the tags, and stop settings from hiding from the page that claims to hold them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scaffolding is down. 203 subject, 2,275 disease and 4,281 keyword tags, and 25,356 links, deleted — backed up first to a 1.9MB JSON of replayable rows, because "we can always put it back" should be true rather than said. The 16 system rows stay: categories point at them. With them go the things that only existed to feed them — the classify_questions task, its snapshot helpers, POST /tags/classify and its status poll — and the three Taxonomy tabs that would now always read zero. A tab showing 0 forever teaches people the page is broken. The organ-system filter in the session builder moved onto categories with the rest, including everything beneath a matched topic, so it groups the way the analysis does. Registration: `settings:registration_enabled` was set to false, and there was no switch anywhere on the site to set it back. The API had always accepted it; the Site policy page had never shown it. So the site could be closed to new members with the admin looking at three switches, all correct, and no way to see the one that was actually refusing them. It is now the first switch on that page, and says plainly that the ones below it have nothing to act on while it is off. The SSO-only flag was hidden the same way and is shown when SSO is configured. Deleting a topic no longer silently unfiles its questions. It asks where they go, and says how many are waiting, unless the topic is empty — the same rule promotion now follows. Its extra category links move too, minus any that would duplicate a pair the destination already has. Back links: Trash, Extraction jobs, Taxonomy and the Handbook had none at all, and Access pointed at the wrong section. They are one component now, each returning one step to the section it was opened from. Editorial has its own entry in the section bar, so its Tools card is gone rather than being a second door to the same room. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- backend/app/routers/question_categories.py | 28 ++- backend/app/routers/tags.py | 33 ---- backend/app/services/quiz_builder.py | 33 ++-- backend/app/tasks/quiz_tasks.py | 195 --------------------- backend/scripts/retire_question_tags.py | 88 ++++++++++ backend/tests/test_category_migration.py | 52 ++++++ frontend/src/components/BackLink.css | 5 + frontend/src/components/BackLink.jsx | 14 ++ frontend/src/components/SitePolicy.css | 8 + frontend/src/components/SitePolicy.jsx | 53 +++++- frontend/src/pages/AccessPage.jsx | 2 +- frontend/src/pages/CategoriesPage.jsx | 17 +- frontend/src/pages/CategoriesPage.test.jsx | 81 ++------- frontend/src/pages/DocumentDetailPage.css | 5 - frontend/src/pages/DocumentDetailPage.jsx | 3 +- frontend/src/pages/HandbookPage.jsx | 2 + frontend/src/pages/JobsPage.jsx | 2 + frontend/src/pages/SettingsPage.jsx | 3 +- frontend/src/pages/ToolsPage.css | 5 - frontend/src/pages/ToolsPage.jsx | 3 +- frontend/src/pages/TrashPage.jsx | 2 + 21 files changed, 304 insertions(+), 330 deletions(-) create mode 100644 backend/scripts/retire_question_tags.py create mode 100644 frontend/src/components/BackLink.css create mode 100644 frontend/src/components/BackLink.jsx diff --git a/backend/app/routers/question_categories.py b/backend/app/routers/question_categories.py index 704435a..95e91eb 100644 --- a/backend/app/routers/question_categories.py +++ b/backend/app/routers/question_categories.py @@ -232,7 +232,33 @@ def delete_question_category(cat_id: int, move_to: int | None = None, db: Sessio if any(c.parent_id == cat_id for c in cats): raise HTTPException(400, "Move child categories before deleting this category") validate_parent(cats, cat_id, move_to) - db.query(Question).filter(Question.question_category_id == cat_id).update({"question_category_id": move_to}) + + # Where the questions go has to be said. Deleting a topic used to leave + # them filed nowhere: no discipline, no organ system, no relevance, no row + # on any tab of the analysis — in the bank and invisible to every page that + # counts them. An empty topic can go without being asked. + held = db.query(Question).filter(Question.question_category_id == cat_id).count() + linked = db.query(QuestionCategoryLink).filter( + QuestionCategoryLink.category_id == cat_id).count() + if (held or linked) and move_to is None: + raise HTTPException(400, ( + f"{held or linked} question{'' if (held or linked) == 1 else 's'} are filed here. " + "Choose a topic to move them to before deleting this one.")) + + db.query(Question).filter(Question.question_category_id == cat_id).update( + {"question_category_id": move_to}) + if move_to is not None: + # The extra links go too, minus any that would duplicate a pair the + # destination already has. Written as bulk statements so they land + # before the category is deleted out from under them. + existing = select(QuestionCategoryLink.question_id).where( + QuestionCategoryLink.category_id == move_to) + db.query(QuestionCategoryLink).filter( + QuestionCategoryLink.category_id == cat_id, + QuestionCategoryLink.question_id.in_(existing)).delete(synchronize_session=False) + db.query(QuestionCategoryLink).filter( + QuestionCategoryLink.category_id == cat_id).update( + {"category_id": move_to}, synchronize_session=False) db.delete(cat) db.commit() diff --git a/backend/app/routers/tags.py b/backend/app/routers/tags.py index dca4ca0..cd41a30 100644 --- a/backend/app/routers/tags.py +++ b/backend/app/routers/tags.py @@ -2,7 +2,6 @@ Tables are created via raw SQL in setup_pgvector() to avoid race conditions with multiple workers. """ import logging -import uuid from typing import Literal @@ -56,37 +55,6 @@ def get_tags(db: Session = Depends(get_db), current_user: User = Depends(get_cur return result -@router.post("/classify") -def trigger_classification( - db: Session = Depends(get_db), - current_user: User = Depends(require_moderator), -): - """Admin/moderator: trigger background classification of untagged questions.""" - from app.tasks.quiz_tasks import classify_questions - - job_id = str(uuid.uuid4()) - classify_questions.delay(job_id, current_user.id) - return {"job_id": job_id, "status": "started"} - - -@router.get("/status/{job_id}") -def get_classification_status( - job_id: str, - current_user: User = Depends(get_current_user), -): - """Check classification job progress.""" - import json - import redis - from app.config import settings - - r = redis.from_url(settings.REDIS_URL, decode_responses=True) - status = r.get(f"classify:status:{job_id}") or "unknown" - steps_raw = r.lrange(f"classify:steps:{job_id}", 0, -1) - steps = [json.loads(s) for s in steps_raw] if steps_raw else [] - 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"] @@ -145,7 +113,6 @@ def create_tag(data: TagWrite, db: Session = Depends(get_db), 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 diff --git a/backend/app/services/quiz_builder.py b/backend/app/services/quiz_builder.py index c92d7fc..8654f24 100644 --- a/backend/app/services/quiz_builder.py +++ b/backend/app/services/quiz_builder.py @@ -112,20 +112,27 @@ def filtered_bank_query(db, user, category_ids=(), state="all", shared=False, di else: query = query.filter(Question.id.is_(None)) # No questions match all tags. if system_ids: - from sqlalchemy import inspect as sa_inspect - from sqlalchemy import text as sa_text - if not sa_inspect(db.get_bind()).has_table("question_tags"): + # A question is never filed under an organ system directly — the topic + # it sits under carries one. It used to reach a system through a + # symptom keyword the question happened to mention, and only about half + # of them mentioned one that had been filed. + # + # ANY of these systems, unlike tag_ids above, which is an AND: asking + # for cardiovascular and respiratory means either, because no question + # is both. + wanted = list(dict.fromkeys(system_ids)) + named = [cid for (cid,) in db.query(QuestionCategory.id).filter( + QuestionCategory.system_id.in_(wanted)).all()] + if not named: return query.filter(Question.id.is_(None)) - # A question is never tagged with an organ system directly — it carries - # a symptom keyword that is filed under one. So this matches ANY tag - # beneath the system, unlike tag_ids above, which is an AND across tags. - matching = list(db.execute(sa_text(""" - SELECT DISTINCT l.question_id - FROM question_tag_links l - JOIN question_tags t ON t.id = l.tag_id - WHERE t.parent_id = ANY(:system_ids) OR t.id = ANY(:system_ids) - """), {"system_ids": list(dict.fromkeys(system_ids))}).scalars()) - query = query.filter(Question.id.in_(matching)) if matching else query.filter(Question.id.is_(None)) + # And everything beneath them: a subtopic with no system of its own + # belongs to the system of the topic above it, which is the same rule + # the analysis groups by. + topics = category_descendants(db.query(QuestionCategory).all(), named) + query = query.filter(or_( + Question.question_category_id.in_(topics), + Question.id.in_(select(QuestionCategoryLink.question_id).where( + QuestionCategoryLink.category_id.in_(topics))))) if category_ids: ids = category_descendants(db.query(QuestionCategory).all(), category_ids) query = query.filter(or_( diff --git a/backend/app/tasks/quiz_tasks.py b/backend/app/tasks/quiz_tasks.py index 552fec7..a0e275d 100644 --- a/backend/app/tasks/quiz_tasks.py +++ b/backend/app/tasks/quiz_tasks.py @@ -351,201 +351,6 @@ def extract_quiz( db.close() -CLASSIFY_EXPIRE = 3600 - - -def _push_classify_step(r, job_id: str, step: str, message: str): - key = f"classify:steps:{job_id}" - entry = json.dumps({"step": step, "message": message, "ts": time.time()}) - r.rpush(key, entry) - r.expire(key, CLASSIFY_EXPIRE) - - -def _create_classification_snapshot(db, job_id: str, user_id: int) -> tuple[int, int, int]: - row = db.execute(sa_text(""" - INSERT INTO question_classification_snapshots (job_id, created_by) - VALUES (:job_id, :user_id) - RETURNING id - """), {"job_id": job_id, "user_id": user_id}).fetchone() - snapshot_id = row[0] - - db.execute(sa_text(""" - INSERT INTO question_classification_snapshot_links (snapshot_id, question_id, tag_name, tag_type) - SELECT :snapshot_id, tl.question_id, t.name, t.type - FROM question_tag_links tl - JOIN question_tags t ON t.id = tl.tag_id - ON CONFLICT DO NOTHING - """), {"snapshot_id": snapshot_id}) - - stats = db.execute(sa_text(""" - SELECT COUNT(DISTINCT question_id) AS question_count, COUNT(*) AS link_count - FROM question_classification_snapshot_links - WHERE snapshot_id = :snapshot_id - """), {"snapshot_id": snapshot_id}).fetchone() - question_count = int(stats[0] or 0) - link_count = int(stats[1] or 0) - - db.execute(sa_text(""" - UPDATE question_classification_snapshots - SET question_count = :question_count, link_count = :link_count - WHERE id = :snapshot_id - """), { - "snapshot_id": snapshot_id, - "question_count": question_count, - "link_count": link_count, - }) - db.commit() - return snapshot_id, question_count, link_count - - -@celery_app.task(name="classify_questions", bind=True) -def classify_questions(self, job_id: str, user_id: int): - """Classify untagged questions using AI — subjects, diseases, keywords.""" - r = _redis() - r.set(f"classify:status:{job_id}", "running", ex=CLASSIFY_EXPIRE) - - db = SessionLocal() - try: - from app.models.question import Question - from app.services import ai_service - - _push_classify_step(r, job_id, "snapshot", "Saving rollback snapshot for current classifications...") - snapshot_id, snapshot_questions, snapshot_links = _create_classification_snapshot(db, job_id, user_id) - _push_classify_step( - r, - job_id, - "snapshot", - f"Saved rollback snapshot #{snapshot_id} with {snapshot_questions} tagged questions and {snapshot_links} tag assignments.", - ) - _push_classify_step(r, job_id, "start", "Finding untagged questions...") - - # Get IDs of questions that already have tags - tagged_ids_rows = db.execute(sa_text( - "SELECT DISTINCT question_id FROM question_tag_links" - )).fetchall() - tagged_ids = {row[0] for row in tagged_ids_rows} - - # Get all questions not yet tagged - all_questions = db.query(Question).all() - untagged = [q for q in all_questions if q.id not in tagged_ids] - - if not untagged: - _push_classify_step(r, job_id, "done", "All questions are already tagged.") - r.set(f"classify:status:{job_id}", "completed", ex=CLASSIFY_EXPIRE) - return - - total = len(untagged) - _push_classify_step(r, job_id, "start", f"Found {total} untagged questions. Starting classification...") - - # Get AI model for keyword task - model_id, api_key = ai_service.get_model_for_task(db, "keyword") - - batch_size = 10 - classified = 0 - - for i in range(0, total, batch_size): - if r.get(f"classify:status:{job_id}") == "cancelled": - _push_classify_step(r, job_id, "cancelled", "Job cancelled.") - return - - batch = untagged[i:i + batch_size] - batch_num = (i // batch_size) + 1 - total_batches = (total + batch_size - 1) // batch_size - _push_classify_step(r, job_id, "progress", - f"Batch {batch_num}/{total_batches}: classifying {len(batch)} questions...") - - # Build questions JSON for prompt - questions_json = json.dumps([ - {"id": q.id, "question_text": q.question_text[:500]} - for q in batch - ], indent=2) - - prompt = f"""Classify each medical question below. For each question, provide: -- subjects: 1-3 medical subjects/specialties (e.g., "Cardiology", "Infectious Disease", "Neonatology") -- diseases: 1-3 specific diseases/conditions mentioned (e.g., "Kawasaki Disease", "Pneumonia", "Type 1 Diabetes") -- keywords: 2-4 key clinical concepts (e.g., "fever workup", "antibiotic resistance", "fluid management") - -Return ONLY JSON: -{{"classifications": [ - {{"id": , "subjects": [...], "diseases": [...], "keywords": [...]}} -]}} - -Questions: -{questions_json}""" - - try: - raw = ai_service._call_model(prompt, model_id, api_key) - # Parse JSON from response - text = raw.strip() - if text.startswith("```"): - text = text.split("\n", 1)[1] if "\n" in text else text[3:] - if text.endswith("```"): - text = text[:-3] - text = text.strip() - data = json.loads(text) - classifications = data.get("classifications", []) - - # Store tags - for cls in classifications: - q_id = cls.get("id") - if not q_id: - continue - - for tag_type, tag_list in [("subject", cls.get("subjects", [])), - ("disease", cls.get("diseases", [])), - ("keyword", cls.get("keywords", []))]: - for tag_name in tag_list: - if not tag_name or not isinstance(tag_name, str): - continue - normalized = tag_name.strip().title() - if not normalized: - continue - - # Insert tag (ON CONFLICT DO NOTHING for case-insensitive uniqueness) - db.execute(sa_text(""" - INSERT INTO question_tags (name, type) - VALUES (:name, :type) - ON CONFLICT (LOWER(name), type) DO NOTHING - """), {"name": normalized, "type": tag_type}) - db.flush() - - # Get the tag ID - tag_row = db.execute(sa_text(""" - SELECT id FROM question_tags - WHERE LOWER(name) = LOWER(:name) AND type = :type - """), {"name": normalized, "type": tag_type}).fetchone() - - if tag_row: - db.execute(sa_text(""" - INSERT INTO question_tag_links (question_id, tag_id) - VALUES (:qid, :tid) - ON CONFLICT DO NOTHING - """), {"qid": q_id, "tid": tag_row[0]}) - - db.commit() - classified += len(batch) - _push_classify_step(r, job_id, "progress", - f"Batch {batch_num}/{total_batches} done. {classified}/{total} classified.") - - except Exception as e: - logger.warning(f"Classification batch {batch_num} failed: {e}") - _push_classify_step(r, job_id, "progress", - f"Batch {batch_num} failed: {e}. Continuing...") - continue - - _push_classify_step(r, job_id, "done", f"Classification complete. {classified}/{total} questions classified.") - r.set(f"classify:status:{job_id}", "completed", ex=CLASSIFY_EXPIRE) - - except Exception as e: - logger.exception(f"Classification failed for job {job_id}") - _push_classify_step(r, job_id, "error", f"Failed: {e}") - r.set(f"classify:status:{job_id}", "failed", ex=CLASSIFY_EXPIRE) - r.set(f"classify:error:{job_id}", str(e)[:500], ex=CLASSIFY_EXPIRE) - raise - finally: - db.close() - - @celery_app.task(name="retry_missing_embeddings") def retry_missing_embeddings(batch: int = 200) -> dict: """Backfill questions that have no usable vector. diff --git a/backend/scripts/retire_question_tags.py b/backend/scripts/retire_question_tags.py new file mode 100644 index 0000000..6131430 --- /dev/null +++ b/backend/scripts/retire_question_tags.py @@ -0,0 +1,88 @@ +"""Take down the scaffolding: the subject, disease and keyword tags. + +A model tagged every question once; `sanitize_tags` cleaned the vocabulary; +`convert_tags_to_categories` turned those tags into the category tree. The tree +is now the taxonomy — every question has a category, and every category carries +its organ system — so the tags underneath it have nothing left to do. + + subject became the discipline a topic sits under + disease became the topic itself + keyword was the old route to an organ system, which now comes from the + category, reaching 2,919 of 2,924 questions instead of 1,492 + +The sixteen `system` rows stay: `question_categories.system_id` points at them, +and they are the vocabulary that names each axis row. + +Everything removed is written to /backups first, as rows that could be replayed +— this deletes a quarter of a million facts about the bank, and "we can always +put it back" should be true rather than said. + +Idempotent, and a dry run by default: + + docker compose exec backend python -m scripts.retire_question_tags + docker compose exec backend python -m scripts.retire_question_tags --apply +""" +import json +import sys +from datetime import datetime +from pathlib import Path + +from sqlalchemy import text as sa_text + +from app.database import SessionLocal + +GOING = ("subject", "disease", "keyword") +BACKUP_DIR = Path("/backups") + + +def main(apply: bool) -> int: + db = SessionLocal() + try: + tags = db.execute(sa_text( + "SELECT id, name, type, parent_id FROM question_tags WHERE type = ANY(:t)"), + {"t": list(GOING)}).mappings().all() + if not tags: + print("Nothing to retire — the tags are already gone.") + return 0 + ids = [row["id"] for row in tags] + links = db.execute(sa_text( + "SELECT question_id, tag_id FROM question_tag_links WHERE tag_id = ANY(:ids)"), + {"ids": ids}).mappings().all() + staying = db.execute(sa_text( + "SELECT count(*) FROM question_tags WHERE type = 'system'")).scalar() + orphaned = db.execute(sa_text( + "SELECT count(*) FROM question_categories WHERE system_id IS NOT NULL")).scalar() + + by_type = {t: sum(1 for row in tags if row["type"] == t) for t in GOING} + print("retiring") + for name, n in by_type.items(): + print(f" {name:<10} {n} tags") + print(f" links {len(links)}") + print(f"\nstaying") + print(f" system {staying} tags, named by {orphaned} topics") + + if not apply: + print("\ndry run. Pass --apply to write.") + return 0 + + BACKUP_DIR.mkdir(parents=True, exist_ok=True) + stamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S") + path = BACKUP_DIR / f"question_tags_retired_{stamp}.json" + path.write_text(json.dumps({ + "retired_at": stamp, + "tags": [dict(row) for row in tags], + "links": [dict(row) for row in links], + }, indent=1)) + print(f"\nbacked up to {path} ({path.stat().st_size:,} bytes)") + + db.execute(sa_text("DELETE FROM question_tag_links WHERE tag_id = ANY(:ids)"), {"ids": ids}) + db.execute(sa_text("DELETE FROM question_tags WHERE id = ANY(:ids)"), {"ids": ids}) + db.commit() + print(f"deleted {len(tags)} tags and {len(links)} links.") + return 0 + finally: + db.close() + + +if __name__ == "__main__": + sys.exit(main("--apply" in sys.argv)) diff --git a/backend/tests/test_category_migration.py b/backend/tests/test_category_migration.py index c73b73e..25a9c08 100644 --- a/backend/tests/test_category_migration.py +++ b/backend/tests/test_category_migration.py @@ -6,7 +6,9 @@ import unittest from unittest.mock import patch os.environ["DATABASE_URL"] = "sqlite:///:memory:" +import test_quiz_builder as fixtures from app.database import Base # Initialize the app engine with disposable SQLite before offline URL override. +from app.models.question import Question from alembic import command from alembic.config import Config from alembic.script import ScriptDirectory @@ -39,3 +41,53 @@ class CategoryMigrationTests(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class DeletingATopicTests(unittest.TestCase): + """Where the questions go has to be said. + + Deleting a topic used to leave them filed nowhere — no discipline, no + organ system, no relevance, no row on any tab of the analysis. In the bank + and invisible to every page that counts them. + """ + + def setUp(self): + self.bank = fixtures.BuilderTests() + self.bank.setUp() + self.client = self.bank.client + self.db = self.bank.db + self.bank.user = self.bank.mod + + def tearDown(self): + self.bank.tearDown() + + def test_a_topic_holding_questions_will_not_go_quietly(self): + # Category 3 holds question 3 in the fixture bank. + response = self.client.delete('/question-categories/3') + self.assertEqual(response.status_code, 400, response.text) + self.assertIn('Choose a topic to move them to', response.json()['detail']) + self.assertIsNotNone(self.db.get(Question, 3).question_category_id) + + def test_naming_a_destination_moves_them_and_deletes_the_topic(self): + from app.models.question_category import QuestionCategory + response = self.client.delete('/question-categories/3', params={'move_to': 2}) + self.assertEqual(response.status_code, 204, response.text) + self.assertEqual(self.db.get(Question, 3).question_category_id, 2) + self.assertIsNone(self.db.get(QuestionCategory, 3)) + + def test_an_empty_topic_goes_without_being_asked(self): + from app.models.question_category import QuestionCategory + # Category 4 holds nothing. + self.assertEqual(self.client.delete('/question-categories/4').status_code, 204) + self.assertIsNone(self.db.get(QuestionCategory, 4)) + + def test_an_extra_link_moves_too_and_does_not_duplicate(self): + from app.models.question_category import QuestionCategoryLink + # Question 1 is filed under category 1 and also linked to 3. + self.db.add(QuestionCategoryLink(question_id=1, category_id=3)) + self.db.add(QuestionCategoryLink(question_id=1, category_id=2)) + self.db.commit() + self.assertEqual( + self.client.delete('/question-categories/3', params={'move_to': 2}).status_code, 204) + pairs = self.db.query(QuestionCategoryLink).filter_by(question_id=1, category_id=2).count() + self.assertEqual(pairs, 1) diff --git a/frontend/src/components/BackLink.css b/frontend/src/components/BackLink.css new file mode 100644 index 0000000..1254885 --- /dev/null +++ b/frontend/src/components/BackLink.css @@ -0,0 +1,5 @@ +.backlink { + display: inline-block; margin-bottom: 12px; + font-size: 0.85rem; text-decoration: none; color: var(--primary); +} +.backlink:hover { text-decoration: underline; } diff --git a/frontend/src/components/BackLink.jsx b/frontend/src/components/BackLink.jsx new file mode 100644 index 0000000..ff52098 --- /dev/null +++ b/frontend/src/components/BackLink.jsx @@ -0,0 +1,14 @@ +import { Link } from 'react-router-dom' +import './BackLink.css' + +/** + * The way back out of a page that has no menu of its own. + * + * One step, to where you came from — not to the top of Settings. A page + * reached from the Tools section returns to the Tools section, so following a + * link and changing your mind costs one click and lands you where you were + * looking, with the other tools still in front of you. + */ +export default function BackLink({ to, children }) { + return ← {children} +} diff --git a/frontend/src/components/SitePolicy.css b/frontend/src/components/SitePolicy.css index b765de5..816e1c6 100644 --- a/frontend/src/components/SitePolicy.css +++ b/frontend/src/components/SitePolicy.css @@ -50,3 +50,11 @@ .sp-code-actions { width: 100%; } .sp-code-actions .btn { flex: 1; } } + +/* Said once, where the switch that caused it is. */ +.sp-note { + margin: -4px 0 14px; padding: 10px 13px; border-radius: 9px; + font-size: 0.82rem; line-height: 1.55; + background: var(--wrong-bg); color: var(--wrong-fg); + border: 1px solid var(--wrong-bd); +} diff --git a/frontend/src/components/SitePolicy.jsx b/frontend/src/components/SitePolicy.jsx index 3eb73fb..25e77ee 100644 --- a/frontend/src/components/SitePolicy.jsx +++ b/frontend/src/components/SitePolicy.jsx @@ -15,7 +15,11 @@ const when = (value) => (value ? new Date(value).toLocaleDateString(undefined, * something a learner has already handed to someone — it stops new ones. */ export default function SitePolicy() { - const [flags, setFlags] = useState({ sharing_enabled: true, invite_only: false, tutor_in_quiz: true }) + const [flags, setFlags] = useState({ + registration_enabled: true, sharing_enabled: true, invite_only: false, + tutor_in_quiz: true, sso_only: false, + }) + const [sso, setSso] = useState({ configured: false, name: '' }) const [codes, setCodes] = useState([]) const [loading, setLoading] = useState(true) const [busy, setBusy] = useState(false) @@ -27,9 +31,15 @@ export default function SitePolicy() { Promise.all([api.get('/admin/settings'), api.get('/admin/invites')]) .then(([settings, invites]) => { setFlags({ + registration_enabled: settings.data.registration_enabled !== false, sharing_enabled: settings.data.sharing_enabled !== false, invite_only: settings.data.invite_only === true, tutor_in_quiz: settings.data.tutor_in_quiz !== false, + sso_only: settings.data.sso_only === true, + }) + setSso({ + configured: !!settings.data.sso_configured, + name: settings.data.sso_provider_name || 'your identity provider', }) setCodes(invites.data || []) }) @@ -74,8 +84,31 @@ export default function SitePolicy() {
{error &&

{error}

} + {/* First, because it decides whether any of the rest is reachable. It + was settable through the API and shown nowhere, so a site could be + closed to new members with no switch anywhere to reopen it — which is + exactly what happened here. */} + + {!flags.registration_enabled && ( +

+ Registration is closed, so the settings below have nothing to act on + until it is reopened. +

+ )} + + - {flags.invite_only && ( + {sso.configured && ( + + )} + + {flags.invite_only && flags.registration_enabled && (

Invite codes {open.length} unused

diff --git a/frontend/src/pages/AccessPage.jsx b/frontend/src/pages/AccessPage.jsx index 6e1f333..d3e3a8a 100644 --- a/frontend/src/pages/AccessPage.jsx +++ b/frontend/src/pages/AccessPage.jsx @@ -176,7 +176,7 @@ export default function AccessPage() { articles. Image libraries are granted one by one.

- Back to settings + Back to Tools {error &&

{error}

} diff --git a/frontend/src/pages/CategoriesPage.jsx b/frontend/src/pages/CategoriesPage.jsx index b2c34fe..1acab9e 100644 --- a/frontend/src/pages/CategoriesPage.jsx +++ b/frontend/src/pages/CategoriesPage.jsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react' -import { Link } from 'react-router-dom' import api from '../api/client' import './CategoriesPage.css' +import BackLink from '../components/BackLink' const apiError = (err, fallback) => { const detail = err?.response?.data?.detail @@ -18,17 +18,16 @@ const apiError = (err, fallback) => { * whoever is curating them they are the same job — the storage split is an * implementation detail, not something to make somebody navigate around. */ +// Two axes, because there are two. Symptoms, Diseases and Subjects were tag +// vocabularies that a model wrote per question; they were converted into this +// topic tree and then kept on as tabs that no longer decided anything. They +// have been retired, and their tabs with them — a tab that always reads zero +// teaches people the page is broken. const FACETS = [ { key: 'topics', label: 'Topics', singular: 'topic', source: 'category', blurb: 'The discipline tree — Cardiology, Neonatology, and what sits beneath them.' }, { key: 'systems', label: 'Systems', singular: 'system', source: 'tag', type: 'system', group: 'systems', - blurb: 'Organ systems, deliberately flat. Symptoms and diseases hang off these.' }, - { key: 'symptoms', label: 'Symptoms', singular: 'symptom', source: 'tag', type: 'keyword', group: 'keywords', - blurb: 'Presenting complaints, grouped by the system they present in.' }, - { key: 'diseases', label: 'Diseases', singular: 'disease', source: 'tag', type: 'disease', group: 'diseases', - blurb: 'Named conditions.' }, - { key: 'subjects', label: 'Subjects', singular: 'subject', source: 'tag', type: 'subject', group: 'subjects', - blurb: 'Broad exam subjects.' }, + blurb: 'Organ systems, deliberately flat. A topic carries one, which is how a question reaches it.' }, ] // Symptoms alone run to four thousand rows. Drawing them all costs a second of @@ -384,13 +383,13 @@ export default function CategoriesPage() { return (
+ Tools

Taxonomy

Every axis a question can be filed under. Anything added here shows up in the question bank and quiz builder straight away.

- Questions diff --git a/frontend/src/pages/CategoriesPage.test.jsx b/frontend/src/pages/CategoriesPage.test.jsx index 2ec4348..865beff 100644 --- a/frontend/src/pages/CategoriesPage.test.jsx +++ b/frontend/src/pages/CategoriesPage.test.jsx @@ -101,14 +101,30 @@ it('deletes a leaf category, rehoming its questions', async () => { { params: { move_to: 3 } })) }) -it('leaves the questions unfiled when no destination is picked', async () => { +it('the server refuses a delete with nowhere to put the questions', async () => { mount() await screen.findByText('Root') - api.delete.mockResolvedValue({}) + // A topic emptied of its filing leaves its questions with no discipline, no + // organ system and no row on any tab of the analysis, so the server will not + // do it. The page shows what it said. + api.delete.mockRejectedValue({ response: { data: { + detail: '4 questions are filed here. Choose a topic to move them to before deleting this one.' } } }) await userEvent.click(screen.getByRole('button', { name: 'Expand Root' })) await userEvent.click(screen.getByRole('button', { name: 'Delete Child' })) await userEvent.click(screen.getByRole('button', { name: 'Delete topic' })) - await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/question-categories/2', { params: {} })) + expect(await screen.findByText(/Choose a topic to move them to/)).toBeInTheDocument() +}) + +it('offers only the two axes there are', async () => { + mount() + await screen.findByText('Root') + // Symptoms, Diseases and Subjects were tag vocabularies converted into this + // tree and then kept on as tabs that always read zero. + expect(screen.getByRole('tab', { name: /Topics/ })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: /Systems/ })).toBeInTheDocument() + for (const gone of ['Symptoms', 'Diseases', 'Subjects']) { + expect(screen.queryByRole('tab', { name: new RegExp(gone) })).toBeNull() + } }) it('refuses to delete a category that still has subcategories', async () => { @@ -154,65 +170,6 @@ it('surfaces a server refusal', async () => { // ---- tag facets ---- -it('shows symptoms under the system they present in', async () => { - mount() - await screen.findByText('Root') - await openFacet('Symptoms') - - // The system is drawn in as a heading, or its 726 children would have no root. - const heading = (await screen.findByText('Respiratory System')).closest('.cat-row') - expect(within(heading).getByText('system')).toBeInTheDocument() - // A heading is not editable from this tab — it belongs to the Systems tab. - expect(within(heading).queryByRole('button', { name: /^Edit/ })).not.toBeInTheDocument() - - expect(screen.queryByText('Cough')).not.toBeInTheDocument() - await userEvent.click(screen.getByRole('button', { name: 'Expand Respiratory System' })) - expect(screen.getByText('Cough')).toBeInTheDocument() - - // A system nobody files under is not drawn at all. - expect(screen.queryByText('Cardiovascular System')).not.toBeInTheDocument() - // An ungrouped symptom stays at the top level rather than disappearing. - expect(screen.getByText('Fatigue')).toBeInTheDocument() -}) - -it('creates a symptom under a system, which the tags API allows', async () => { - mount() - await screen.findByText('Root') - await openFacet('Symptoms') - api.post.mockResolvedValue({ data: { id: 99 } }) - - await userEvent.click(screen.getByRole('button', { name: '+ New symptom' })) - await userEvent.type(screen.getByLabelText('New symptom name'), 'Stridor') - await userEvent.selectOptions(screen.getByLabelText('New symptom parent'), '10') - await userEvent.click(screen.getByRole('button', { name: 'Create' })) - - await waitFor(() => expect(api.post).toHaveBeenCalledWith('/tags/', - { name: 'Stridor', type: 'keyword', parent_id: 10, sort_order: 100 })) - expect(await screen.findByRole('status')).toHaveTextContent('in every picker now') -}) - -it('offers systems as parents on the symptom tab', async () => { - mount() - await screen.findByText('Root') - await openFacet('Symptoms') - await userEvent.click(await screen.findByRole('button', { name: '+ New symptom' })) - const parent = screen.getByLabelText('New symptom parent') - expect([...parent.options].map(o => o.text)).toContain('Respiratory System (system)') - expect([...parent.options].map(o => o.text)).toContain('Fatigue') -}) - -it('deletes a tag, saying its children rise rather than refusing', async () => { - mount() - await screen.findByText('Root') - await openFacet('Symptoms') - api.delete.mockResolvedValue({}) - - await userEvent.click(await screen.findByRole('button', { name: 'Delete Fatigue' })) - expect(screen.getByRole('alert')).toHaveTextContent('Move its 3 questions to') - await userEvent.click(screen.getByRole('button', { name: 'Delete symptom' })) - await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/tags/21', { params: {} })) -}) - it('does not search the question bank from here', async () => { mount() await screen.findByText('Root') diff --git a/frontend/src/pages/DocumentDetailPage.css b/frontend/src/pages/DocumentDetailPage.css index e6fc1f1..6748faf 100644 --- a/frontend/src/pages/DocumentDetailPage.css +++ b/frontend/src/pages/DocumentDetailPage.css @@ -1,8 +1,3 @@ -.doc-back { - display: inline-block; margin-bottom: 10px; - font-size: 0.85rem; text-decoration: none; color: var(--primary); -} -.doc-back:hover { text-decoration: underline; } /* ── A section and what can be done to it ───────────────────────────── The name took whatever width was left after a button group that would diff --git a/frontend/src/pages/DocumentDetailPage.jsx b/frontend/src/pages/DocumentDetailPage.jsx index cc60a75..af93562 100644 --- a/frontend/src/pages/DocumentDetailPage.jsx +++ b/frontend/src/pages/DocumentDetailPage.jsx @@ -4,6 +4,7 @@ import { useAuth } from '../context/AuthContext' import api from '../api/client' import ConfirmButton from '../components/ConfirmButton' import './DocumentDetailPage.css' +import BackLink from '../components/BackLink' function ExtractionProgress({ jobId, onDone, onClose, label = 'Extracting Questions' }) { const [steps, setSteps] = useState([]) @@ -292,7 +293,7 @@ export default function DocumentDetailPage() { )} {/* Reached from the Tools workbench, which has no menu of its own — so without this there is no way back but the browser button. */} - ← Tools + Question workbench
diff --git a/frontend/src/pages/HandbookPage.jsx b/frontend/src/pages/HandbookPage.jsx index 126ea46..b1e6f6a 100644 --- a/frontend/src/pages/HandbookPage.jsx +++ b/frontend/src/pages/HandbookPage.jsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { Link } from 'react-router-dom' import api from '../api/client' import './HandbookPage.css' +import BackLink from '../components/BackLink' /** * How this site works, for the people who maintain it. @@ -54,6 +55,7 @@ function Prompt() { export default function HandbookPage() { return (
+ Tools

Handbook

diff --git a/frontend/src/pages/JobsPage.jsx b/frontend/src/pages/JobsPage.jsx index 1388988..3c92408 100644 --- a/frontend/src/pages/JobsPage.jsx +++ b/frontend/src/pages/JobsPage.jsx @@ -3,6 +3,7 @@ import { Link, useNavigate } from 'react-router-dom' import api from '../api/client' import Dialog from '../components/Dialog' import { useDialog } from '../hooks/useDialog' +import BackLink from '../components/BackLink' function JobDetail({ job }) { const [steps, setSteps] = useState([]) @@ -157,6 +158,7 @@ export default function JobsPage() { return (

+ Tools

Extraction Jobs

diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index 7dbd0d9..f3d7bf7 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -222,6 +222,8 @@ function NextcloudSection() { * page's own sections rather than behind a link to a second dashboard. */ function ToolsSection() { + // Editorial is not here: it has its own entry in the section bar, and a card + // pointing at it would be a second door to the same room. return (

@@ -229,7 +231,6 @@ function ToolsSection() { { to: '/handbook', icon: '📖', label: 'Handbook', desc: 'How the parts that are not obvious work' }, { to: '/tools', icon: '🧪', label: 'Question workbench', desc: 'PDFs in, drafts read, questions out' }, { to: '/categories', icon: '🗂️', label: 'Taxonomy', desc: 'Topics, systems, symptoms, diseases' }, - { to: '/editorial', icon: '✍️', label: 'Editorial', desc: 'Article drafts and references' }, { to: '/access', icon: '🔑', label: 'Access', desc: 'Who may edit what' }, { to: '/trash', icon: '🗑️', label: 'Trash', desc: 'Restore deleted questions' }, { to: '/jobs', icon: '📋', label: 'Extraction jobs', desc: 'Extraction history' }, diff --git a/frontend/src/pages/ToolsPage.css b/frontend/src/pages/ToolsPage.css index 71c1158..ced58ea 100644 --- a/frontend/src/pages/ToolsPage.css +++ b/frontend/src/pages/ToolsPage.css @@ -1,10 +1,5 @@ .tools { max-width: 900px; margin: 0 auto; padding-bottom: 64px; } .tools-head { margin-bottom: 24px; } -.tools-back { - display: inline-block; margin-bottom: 10px; - font-size: 0.85rem; text-decoration: none; color: var(--primary); -} -.tools-back:hover { text-decoration: underline; } .tools-head h1 { margin: 0 0 8px; font-size: 1.5rem; font-weight: 700; } .tools-head p { margin: 0; font-size: 0.92rem; line-height: 1.65; color: var(--text-muted); max-width: 66ch; } .tools-error { margin: 0 0 14px; font-size: 0.85rem; color: var(--wrong-fg); } diff --git a/frontend/src/pages/ToolsPage.jsx b/frontend/src/pages/ToolsPage.jsx index 5e7536e..c043772 100644 --- a/frontend/src/pages/ToolsPage.jsx +++ b/frontend/src/pages/ToolsPage.jsx @@ -3,6 +3,7 @@ import { Link } from 'react-router-dom' import api from '../api/client' import { useAuth } from '../context/AuthContext' import './ToolsPage.css' +import BackLink from '../components/BackLink' const DraftBatch = lazy(() => import('../components/DraftBatch')) @@ -51,7 +52,7 @@ export default function ToolsPage() { {/* The way back. This page is a workbench rather than part of the study flow, and without a trail out of it you are somewhere that looks like a different application. */} - ← Settings + Tools in Settings

Tools

A document goes in, a model proposes questions, you read them, and the diff --git a/frontend/src/pages/TrashPage.jsx b/frontend/src/pages/TrashPage.jsx index d64bfac..a1a0b83 100644 --- a/frontend/src/pages/TrashPage.jsx +++ b/frontend/src/pages/TrashPage.jsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react' import { Link } from 'react-router-dom' import api from '../api/client' import ConfirmButton from '../components/ConfirmButton' +import BackLink from '../components/BackLink' const when = value => (value ? new Date(value).toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' }) : '') @@ -46,6 +47,7 @@ export default function TrashPage() { return (

+ Tools

Trash