Two shapes, because a plan is asked to do two different things. Papers are rehearsal: each block is drawn to the ABP's published weights, so sitting one says something about how you would do on the day. Domains are study: the board's twenty-four content areas in its own order and carrying its own titles, each given the share of the plan the board gives it on the exam. Both were written, then run against the real bank, which found two bugs a unit test on a clean fixture would not have. Domains 19 and 20 — nephrology and genitourinary — both map to our "Nephrology & Urology", so a question sat in two pools and was dealt twice; the deal now keeps a record of what has gone. And chunking every question a domain has into blocks of forty gave preventive care six blocks and the plan a hundred and sixty, which is not a plan: blocks are shared out by weight, with at least one per domain so nothing the board examines is left out. Built on the live bank alongside what was already there: Boards: Full Papers (12 × 40) and Boards: By Content Domain (27 blocks, 1069 questions). Nothing existing was touched. Psychosocial Issues and Child Abuse and Neglect — 6% of the paper between them — had no category of ours at all, so they could contribute nothing. Both now exist, with sub-topics named from the board's own subdomains, and all 24 domains map to categories. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
478 lines
20 KiB
Python
478 lines
20 KiB
Python
"""Study plans — ordered blocks of reading and questions a learner works through."""
|
|
import logging
|
|
import re
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.database import get_db
|
|
from app.models.article import Article
|
|
from app.models.exam import Exam
|
|
from app.models.question import Question
|
|
from app.models.study_plan import (
|
|
StudyPlan, StudyPlanArticleRead, StudyPlanBlock, StudyPlanBlockArticle,
|
|
StudyPlanBlockProgress,
|
|
)
|
|
from app.models.user import User
|
|
from app.services import exam_blueprint
|
|
from app.services.quiz_builder import (GenerateTestRequest, bank_query, create_saved_test,
|
|
general_question_predicate)
|
|
from app.utils.auth import get_current_user, require_moderator
|
|
|
|
router = APIRouter()
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
def _reading_for(db: Session, user: User, block_ids: list[int]) -> dict[int, list[dict]]:
|
|
"""Each block's reading, in order, with whether this learner has finished it."""
|
|
if not block_ids:
|
|
return {}
|
|
rows = db.query(StudyPlanBlockArticle, Article).join(
|
|
Article, Article.id == StudyPlanBlockArticle.article_id).filter(
|
|
StudyPlanBlockArticle.block_id.in_(block_ids)).order_by(
|
|
StudyPlanBlockArticle.position, StudyPlanBlockArticle.id).all()
|
|
read = {row[0] for row in db.query(StudyPlanArticleRead.block_article_id).filter(
|
|
StudyPlanArticleRead.user_id == user.id).all()}
|
|
out: dict[int, list[dict]] = {}
|
|
for link, article in rows:
|
|
# A draft is still listed for the educator who can open it, and left out
|
|
# for everyone else rather than offered as a dead link.
|
|
if article.status != "published" and not user.is_moderator and article.user_id != user.id:
|
|
continue
|
|
out.setdefault(link.block_id, []).append({
|
|
"link_id": link.id, "article_id": article.id, "slug": article.slug,
|
|
"title": article.title, "summary": article.summary,
|
|
"status": article.status, "read": link.id in read,
|
|
})
|
|
return out
|
|
|
|
|
|
@router.get("/")
|
|
def list_study_plans(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
|
"""Published plans, with how far this learner has got through each."""
|
|
query = db.query(StudyPlan)
|
|
if not current_user.is_moderator:
|
|
query = query.filter(StudyPlan.is_published == 1)
|
|
plans = query.order_by(StudyPlan.sort_order, StudyPlan.name).all()
|
|
if not plans:
|
|
return []
|
|
exams = {e.id: e.name for e in db.query(Exam).all()}
|
|
done = {
|
|
row[0] for row in db.query(StudyPlanBlockProgress.block_id).filter(
|
|
StudyPlanBlockProgress.user_id == current_user.id,
|
|
StudyPlanBlockProgress.completed_at.isnot(None)).all()
|
|
}
|
|
out = []
|
|
for plan in plans:
|
|
blocks = plan.blocks
|
|
out.append({
|
|
"id": plan.id, "slug": plan.slug, "name": plan.name,
|
|
"description": plan.description, "kind": plan.kind,
|
|
"exam_name": exams.get(plan.exam_id),
|
|
"is_published": bool(plan.is_published),
|
|
"block_count": len(blocks),
|
|
"question_count": sum(len(b.question_ids or []) for b in blocks),
|
|
"blocks_completed": sum(1 for b in blocks if b.id in done),
|
|
})
|
|
return out
|
|
|
|
|
|
@router.get("/{plan_id}")
|
|
def get_study_plan(plan_id: int, db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)):
|
|
plan = db.get(StudyPlan, plan_id)
|
|
if not plan or (not plan.is_published and not current_user.is_moderator):
|
|
raise HTTPException(404, "Study plan not found")
|
|
progress = {
|
|
row.block_id: row for row in db.query(StudyPlanBlockProgress).filter(
|
|
StudyPlanBlockProgress.user_id == current_user.id).all()
|
|
}
|
|
reading = _reading_for(db, current_user, [block.id for block in plan.blocks])
|
|
return {
|
|
"id": plan.id, "slug": plan.slug, "name": plan.name,
|
|
"description": plan.description, "kind": plan.kind,
|
|
"is_published": bool(plan.is_published),
|
|
"blocks": [{
|
|
"id": block.id, "position": block.position, "title": block.title,
|
|
"question_count": len(block.question_ids or []),
|
|
"quiz_id": progress.get(block.id).quiz_id if block.id in progress else None,
|
|
"completed": bool(progress.get(block.id) and progress[block.id].completed_at),
|
|
# Reading first, then the questions it prepares you for.
|
|
"articles": reading.get(block.id, []),
|
|
} for block in plan.blocks],
|
|
}
|
|
|
|
|
|
@router.post("/blocks/{block_id}/start")
|
|
def start_block(block_id: int, mode: str = "learning", db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)):
|
|
"""Turn a block into a test. Reuses the learner's existing quiz for the block.
|
|
|
|
Without the reuse, reopening a block would create a duplicate test each time
|
|
and scatter the learner's attempts across them.
|
|
"""
|
|
block = db.get(StudyPlanBlock, block_id)
|
|
if not block:
|
|
raise HTTPException(404, "Block not found")
|
|
if mode not in ("learning", "timed"):
|
|
raise HTTPException(400, "Mode must be learning or timed")
|
|
|
|
row = db.query(StudyPlanBlockProgress).filter_by(block_id=block.id, user_id=current_user.id).first()
|
|
if row and row.quiz_id:
|
|
return {"id": row.quiz_id, "reused": True}
|
|
|
|
# Only questions this learner may actually see.
|
|
visible = {
|
|
qid for (qid,) in bank_query(db, current_user)
|
|
.with_entities(Question.id)
|
|
.filter(Question.id.in_(block.question_ids or []))
|
|
.all()
|
|
}
|
|
ids = [qid for qid in (block.question_ids or []) if qid in visible]
|
|
if not ids:
|
|
raise HTTPException(400, "No questions in this block are available to you")
|
|
|
|
plan = db.get(StudyPlan, block.plan_id)
|
|
created = create_saved_test(
|
|
db, current_user,
|
|
GenerateTestRequest(title=f"{plan.name} — {block.title}", count=len(ids),
|
|
mode=mode, time_limit_minutes=None, is_shared=False),
|
|
ids,
|
|
)
|
|
if row is None:
|
|
row = StudyPlanBlockProgress(block_id=block.id, user_id=current_user.id)
|
|
db.add(row)
|
|
row.quiz_id = created["id"]
|
|
db.commit()
|
|
return {**created, "reused": False}
|
|
|
|
|
|
@router.post("/reading/{link_id}/read")
|
|
def mark_reading(link_id: int, read: bool = True, db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)):
|
|
"""Mark one of a block's articles as read, or take that back.
|
|
|
|
Reversible on purpose: a learner who ticks the wrong row should be able to
|
|
correct it without an educator, and progress nobody can correct stops being
|
|
trusted and then stops being used.
|
|
"""
|
|
link = db.get(StudyPlanBlockArticle, link_id)
|
|
if not link:
|
|
raise HTTPException(404, "That reading is not part of a block")
|
|
row = db.query(StudyPlanArticleRead).filter_by(
|
|
block_article_id=link_id, user_id=current_user.id).first()
|
|
if read and not row:
|
|
db.add(StudyPlanArticleRead(block_article_id=link_id, user_id=current_user.id))
|
|
elif not read and row:
|
|
db.delete(row)
|
|
db.commit()
|
|
return {"link_id": link_id, "read": read}
|
|
|
|
|
|
# ── Editing, for moderators ───────────────────────────────────────────────────
|
|
|
|
SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
|
|
|
|
class PlanWrite(BaseModel):
|
|
name: str = Field(min_length=1, max_length=200)
|
|
slug: str = Field(min_length=1, max_length=120)
|
|
description: str | None = None
|
|
kind: str = "set"
|
|
exam_id: int | None = None
|
|
sort_order: int = 100
|
|
is_published: bool = True
|
|
|
|
|
|
class PlanUpdate(BaseModel):
|
|
name: str | None = Field(default=None, min_length=1, max_length=200)
|
|
description: str | None = None
|
|
exam_id: int | None = None
|
|
sort_order: int | None = None
|
|
is_published: bool | None = None
|
|
|
|
|
|
def _get_plan(db: Session, plan_id: int) -> StudyPlan:
|
|
plan = db.get(StudyPlan, plan_id)
|
|
if not plan:
|
|
raise HTTPException(404, "Study plan not found")
|
|
return plan
|
|
|
|
|
|
def _get_block(db: Session, block_id: int) -> StudyPlanBlock:
|
|
block = db.get(StudyPlanBlock, block_id)
|
|
if not block:
|
|
raise HTTPException(404, "Block not found")
|
|
return block
|
|
|
|
|
|
@router.post("/", status_code=201)
|
|
def create_plan(data: PlanWrite, db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_moderator)):
|
|
slug = data.slug.strip().lower()
|
|
if not SLUG_RE.match(slug):
|
|
raise HTTPException(400, "A slug is lowercase words joined by hyphens")
|
|
if db.query(StudyPlan.id).filter(StudyPlan.slug == slug).first():
|
|
raise HTTPException(409, "A plan with that slug already exists")
|
|
if data.kind not in ("set", "mixed"):
|
|
raise HTTPException(400, "Kind must be set or mixed")
|
|
plan = StudyPlan(slug=slug, name=data.name.strip(), description=data.description,
|
|
kind=data.kind, exam_id=data.exam_id, sort_order=data.sort_order,
|
|
is_published=1 if data.is_published else 0)
|
|
db.add(plan)
|
|
db.commit()
|
|
return {"id": plan.id, "slug": plan.slug, "name": plan.name}
|
|
|
|
|
|
class BlueprintPlan(BaseModel):
|
|
exam_id: int
|
|
slug: str
|
|
name: str
|
|
description: str | None = None
|
|
#: "papers" — every block a miniature of the real exam, for rehearsal.
|
|
#: "domains" — one block per content domain, for working through a subject.
|
|
shape: str = "domains"
|
|
block_size: int = Field(default=40, ge=5, le=200)
|
|
#: How many blocks the plan has. For papers, how many to deal out; for
|
|
#: domains, how they are shared out by weight — every domain still gets at
|
|
#: least one, so nothing the board examines is left out.
|
|
blocks: int = Field(default=24, ge=1, le=80)
|
|
sort_order: int = 100
|
|
is_published: bool = True
|
|
#: Attach the reading filed under each domain to its block.
|
|
with_reading: bool = True
|
|
|
|
|
|
@router.post("/from-blueprint", status_code=201)
|
|
def create_from_blueprint(data: BlueprintPlan, db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_moderator)):
|
|
"""Build a plan to an examining board's published content outline.
|
|
|
|
Two shapes, because a plan is asked to do two different things. Papers are
|
|
rehearsal: each block is drawn to the board's weights, so sitting one tells
|
|
a learner something about how they would do on the day. Domains are study:
|
|
one block per content area, in the board's order and carrying its titles,
|
|
which is how someone works through a subject rather than tests themselves
|
|
on all of it.
|
|
|
|
Membership is snapshotted, as with any plan — a plan you are part-way
|
|
through must not reshuffle between visits — and no question appears in two
|
|
blocks of the same plan.
|
|
"""
|
|
slug = data.slug.strip().lower()
|
|
if not SLUG_RE.match(slug):
|
|
raise HTTPException(400, "A slug is lowercase words joined by hyphens")
|
|
if db.query(StudyPlan.id).filter(StudyPlan.slug == slug).first():
|
|
raise HTTPException(409, "A plan with that slug already exists")
|
|
if data.shape not in ("papers", "domains"):
|
|
raise HTTPException(400, "Shape must be papers or domains")
|
|
exam = db.get(Exam, data.exam_id)
|
|
if not exam:
|
|
raise HTTPException(404, "Exam not found")
|
|
if not exam_blueprint.domains(db, data.exam_id):
|
|
raise HTTPException(400, "That exam has no blueprint to build from")
|
|
|
|
keep = general_question_predicate()
|
|
if data.shape == "papers":
|
|
drawn = exam_blueprint.sample_papers(db, data.exam_id, data.block_size,
|
|
data.blocks, predicate=keep)
|
|
built = [(None, f"Paper {i + 1}", ids) for i, (ids, _) in enumerate(drawn)]
|
|
else:
|
|
built = exam_blueprint.domain_blocks(db, data.exam_id, data.block_size,
|
|
data.blocks, predicate=keep)
|
|
|
|
if not built:
|
|
raise HTTPException(400, "No questions match this blueprint yet")
|
|
|
|
plan = StudyPlan(slug=slug, name=data.name.strip(), description=data.description,
|
|
kind="set", exam_id=data.exam_id, sort_order=data.sort_order,
|
|
is_published=1 if data.is_published else 0)
|
|
db.add(plan)
|
|
db.flush()
|
|
|
|
# Reading per domain, so a block opens on what prepares you for it.
|
|
reading: dict[str, list[int]] = {}
|
|
if data.with_reading:
|
|
for line in exam_blueprint.domains(db, data.exam_id):
|
|
cats = exam_blueprint.categories_for(db, line.id)
|
|
if not cats:
|
|
continue
|
|
reading[line.code] = [row[0] for row in db.query(Article.id).filter(
|
|
Article.category_id.in_(cats), Article.status == "published").limit(6).all()]
|
|
|
|
for position, (code, title, ids) in enumerate(built):
|
|
block = StudyPlanBlock(plan_id=plan.id, position=position, title=title,
|
|
question_ids=list(ids))
|
|
db.add(block)
|
|
db.flush()
|
|
for index, article_id in enumerate(reading.get(code, [])):
|
|
db.add(StudyPlanBlockArticle(block_id=block.id, article_id=article_id, position=index))
|
|
|
|
db.commit()
|
|
return {
|
|
"id": plan.id, "slug": plan.slug, "name": plan.name, "shape": data.shape,
|
|
"blocks": [{"position": i, "title": title, "questions": len(ids)}
|
|
for i, (_, title, ids) in enumerate(built)],
|
|
"questions": sum(len(ids) for _, _, ids in built),
|
|
}
|
|
|
|
|
|
@router.patch("/{plan_id}")
|
|
def update_plan(plan_id: int, data: PlanUpdate, db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_moderator)):
|
|
plan = _get_plan(db, plan_id)
|
|
values = data.model_dump(exclude_unset=True)
|
|
if "is_published" in values:
|
|
plan.is_published = 1 if values.pop("is_published") else 0
|
|
for field, value in values.items():
|
|
setattr(plan, field, value.strip() if isinstance(value, str) else value)
|
|
db.commit()
|
|
return {"id": plan.id}
|
|
|
|
|
|
@router.delete("/{plan_id}", status_code=204)
|
|
def delete_plan(plan_id: int, db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_moderator)):
|
|
"""Remove a plan and its blocks. Tests already generated from it survive.
|
|
|
|
A learner part-way through keeps the quizzes they started; deleting a plan
|
|
is retiring a route through the bank, not confiscating anyone's work.
|
|
"""
|
|
db.delete(_get_plan(db, plan_id))
|
|
db.commit()
|
|
|
|
|
|
class BlockWrite(BaseModel):
|
|
title: str = Field(min_length=1, max_length=200)
|
|
question_ids: list[int] = []
|
|
|
|
|
|
@router.post("/{plan_id}/blocks", status_code=201)
|
|
def add_block(plan_id: int, data: BlockWrite, db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_moderator)):
|
|
plan = _get_plan(db, plan_id)
|
|
position = max((b.position for b in plan.blocks), default=-1) + 1
|
|
block = StudyPlanBlock(plan_id=plan.id, position=position, title=data.title.strip(),
|
|
question_ids=list(dict.fromkeys(data.question_ids)))
|
|
db.add(block)
|
|
db.commit()
|
|
return {"id": block.id, "position": block.position, "title": block.title}
|
|
|
|
|
|
class BlockUpdate(BaseModel):
|
|
title: str | None = Field(default=None, min_length=1, max_length=200)
|
|
question_ids: list[int] | None = None
|
|
|
|
|
|
@router.patch("/blocks/{block_id}")
|
|
def update_block(block_id: int, data: BlockUpdate, db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_moderator)):
|
|
block = _get_block(db, block_id)
|
|
values = data.model_dump(exclude_unset=True)
|
|
if values.get("title"):
|
|
block.title = values["title"].strip()
|
|
if values.get("question_ids") is not None:
|
|
ids = list(dict.fromkeys(values["question_ids"]))
|
|
known = {qid for (qid,) in db.query(Question.id).filter(Question.id.in_(ids)).all()}
|
|
missing = [qid for qid in ids if qid not in known]
|
|
if missing:
|
|
raise HTTPException(400, f"No such question: {', '.join(str(q) for q in missing[:5])}")
|
|
block.question_ids = ids
|
|
db.commit()
|
|
return {"id": block.id, "question_count": len(block.question_ids or [])}
|
|
|
|
|
|
@router.delete("/blocks/{block_id}", status_code=204)
|
|
def delete_block(block_id: int, db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_moderator)):
|
|
"""Delete a block and close the gap it leaves in the numbering."""
|
|
block = _get_block(db, block_id)
|
|
plan_id, position = block.plan_id, block.position
|
|
db.delete(block)
|
|
db.flush()
|
|
# Positions are unique per plan, so the survivors have to shuffle down or
|
|
# the next insert collides with a number nothing occupies.
|
|
for other in db.query(StudyPlanBlock).filter(
|
|
StudyPlanBlock.plan_id == plan_id,
|
|
StudyPlanBlock.position > position).order_by(StudyPlanBlock.position).all():
|
|
other.position -= 1
|
|
db.commit()
|
|
|
|
|
|
class BlockOrder(BaseModel):
|
|
block_ids: list[int] = Field(min_length=1)
|
|
|
|
|
|
@router.post("/{plan_id}/blocks/order")
|
|
def reorder_blocks(plan_id: int, data: BlockOrder, db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_moderator)):
|
|
"""Set the order of a plan's blocks in one go."""
|
|
plan = _get_plan(db, plan_id)
|
|
blocks = {block.id: block for block in plan.blocks}
|
|
if set(data.block_ids) != set(blocks):
|
|
raise HTTPException(400, "List every block of this plan exactly once")
|
|
# Two passes through a unique (plan_id, position) constraint: park the rows
|
|
# out of range first, or the first move collides with a position still held.
|
|
for offset, block_id in enumerate(data.block_ids):
|
|
blocks[block_id].position = -1000 - offset
|
|
db.flush()
|
|
for position, block_id in enumerate(data.block_ids):
|
|
blocks[block_id].position = position
|
|
db.commit()
|
|
return {"plan_id": plan.id, "blocks": data.block_ids}
|
|
|
|
|
|
class MoveQuestions(BaseModel):
|
|
question_ids: list[int] = Field(min_length=1)
|
|
to_block_id: int
|
|
|
|
|
|
@router.post("/blocks/{block_id}/move")
|
|
def move_questions(block_id: int, data: MoveQuestions, db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_moderator)):
|
|
"""Move questions from one block to another within the same plan."""
|
|
source = _get_block(db, block_id)
|
|
target = _get_block(db, data.to_block_id)
|
|
if source.id == target.id:
|
|
raise HTTPException(400, "Pick a different block to move into")
|
|
if source.plan_id != target.plan_id:
|
|
raise HTTPException(400, "Blocks belong to different plans")
|
|
moving = [qid for qid in data.question_ids if qid in (source.question_ids or [])]
|
|
if not moving:
|
|
raise HTTPException(400, "None of those questions are in this block")
|
|
source.question_ids = [qid for qid in (source.question_ids or []) if qid not in moving]
|
|
target.question_ids = list(dict.fromkeys([*(target.question_ids or []), *moving]))
|
|
db.commit()
|
|
return {"moved": len(moving), "from": source.id, "to": target.id}
|
|
|
|
|
|
class BlockArticleIn(BaseModel):
|
|
article_id: int
|
|
|
|
|
|
@router.post("/blocks/{block_id}/articles", status_code=201)
|
|
def attach_article(block_id: int, data: BlockArticleIn, db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_moderator)):
|
|
"""Attach reading to a block, appended after whatever is already there."""
|
|
block = _get_block(db, block_id)
|
|
if not db.get(Article, data.article_id):
|
|
raise HTTPException(404, "Article not found")
|
|
if db.query(StudyPlanBlockArticle.id).filter_by(
|
|
block_id=block.id, article_id=data.article_id).first():
|
|
raise HTTPException(409, "That article is already on this block")
|
|
position = (db.query(StudyPlanBlockArticle).filter_by(block_id=block.id).count())
|
|
link = StudyPlanBlockArticle(block_id=block.id, article_id=data.article_id, position=position)
|
|
db.add(link)
|
|
db.commit()
|
|
return {"link_id": link.id, "block_id": block.id, "article_id": data.article_id}
|
|
|
|
|
|
@router.delete("/reading/{link_id}", status_code=204)
|
|
def detach_article(link_id: int, db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_moderator)):
|
|
link = db.get(StudyPlanBlockArticle, link_id)
|
|
if not link:
|
|
raise HTTPException(404, "That reading is not part of a block")
|
|
db.delete(link)
|
|
db.commit()
|