feat: study plans you can open, work through, and edit

Thirteen plans were seeded with an API to serve them and nothing that called it,
so the whole feature existed only in the database. Two pages and the editing
endpoints it was missing.

/study-plans lists the plans with progress stated in blocks — "3 of 6 blocks"
is something you can act on, where "50%" only tells you how you feel about it.
/study-plans/:id is one plan: each block shows Articles, then Sessions, in that
order, because that is the order the block is meant to be done in.

Reading is now part of a block (migration f4a5b6c7d8e9). "Mark as read" is the
learner's own claim and reversible — someone who ticks the wrong row should be
able to fix it without an educator, and progress nobody can correct stops being
trusted and then stops being used. It is a separate table from `article_views`
on purpose: opening an article is not the same claim as having finished with it.
A draft article attached to a block is listed for the educator who can open it
and left out for everyone else, rather than offered as a dead link.

Editing is inline on the learner's own page rather than a separate builder, so
the thing being changed and the thing a learner sees are the same object.
Moderators create (as a draft — an empty plan is not something to put in front
of anyone), rename, publish, delete; add, rename, reorder and remove blocks;
move questions between blocks of one plan; attach reading found by searching
rather than by id.

Two places where the obvious implementation leaves the data wrong, both tested:
deleting a block out of the middle shuffles the survivors down, or the next
insert collides with a position nothing occupies; and reordering parks every row
outside the range before writing the real positions, because (plan_id, position)
is unique and the first move would otherwise collide with a position still held.
A partial order is refused rather than half-applied.

166 backend, 188 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeFQJXJTfHKTfbfsdxv57Z
This commit is contained in:
Daniel 2026-09-10 12:10:39 +02:00
parent 50ddcafd82
commit 8a07703ee2
11 changed files with 1250 additions and 10 deletions

View file

@ -0,0 +1,42 @@
"""Reading attached to a study plan block, and who has finished it.
Revision ID: f4a5b6c7d8e9
Revises: e3f4a5b6c7d8
"""
import sqlalchemy as sa
from alembic import op
revision = "f4a5b6c7d8e9"
down_revision = "e3f4a5b6c7d8"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"study_plan_block_articles",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("block_id", sa.Integer,
sa.ForeignKey("study_plan_blocks.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("article_id", sa.Integer,
sa.ForeignKey("articles.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("position", sa.Integer, server_default="0"),
sa.UniqueConstraint("block_id", "article_id", name="uq_block_article"),
)
# Opening an article is not the same claim as having finished with it, so
# this is its own table rather than a flag on article_views.
op.create_table(
"study_plan_article_reads",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("block_article_id", sa.Integer,
sa.ForeignKey("study_plan_block_articles.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("user_id", sa.Integer,
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("read_at", sa.DateTime, server_default=sa.func.now()),
sa.UniqueConstraint("block_article_id", "user_id", name="uq_block_article_read"),
)
def downgrade():
op.drop_table("study_plan_article_reads")
op.drop_table("study_plan_block_articles")

View file

@ -44,6 +44,38 @@ class StudyPlanBlock(Base):
plan = relationship("StudyPlan", back_populates="blocks")
class StudyPlanBlockArticle(Base):
"""Reading attached to a block, in the order it should be read.
A block was questions only, which put the reading that prepares you for them
somewhere else entirely. This is the "read this, then sit this" pairing.
"""
__tablename__ = "study_plan_block_articles"
__table_args__ = (UniqueConstraint("block_id", "article_id", name="uq_block_article"),)
id = Column(Integer, primary_key=True, index=True)
block_id = Column(Integer, ForeignKey("study_plan_blocks.id", ondelete="CASCADE"), nullable=False, index=True)
article_id = Column(Integer, ForeignKey("articles.id", ondelete="CASCADE"), nullable=False, index=True)
position = Column(Integer, default=0)
class StudyPlanArticleRead(Base):
"""One learner marking one of a block's articles as read.
Deliberately separate from `article_views`: opening an article is not the
same claim as having finished with it, and the plan's progress is the second.
"""
__tablename__ = "study_plan_article_reads"
__table_args__ = (UniqueConstraint("block_article_id", "user_id", name="uq_block_article_read"),)
id = Column(Integer, primary_key=True, index=True)
block_article_id = Column(Integer, ForeignKey("study_plan_block_articles.id", ondelete="CASCADE"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
read_at = Column(DateTime, default=datetime.utcnow)
class StudyPlanBlockProgress(Base):
"""Which block a learner has started, and the quiz it produced."""

View file

@ -1,26 +1,58 @@
"""Study plans — ordered blocks of questions a learner works through."""
"""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, StudyPlanBlock, StudyPlanBlockProgress
from app.models.study_plan import (
StudyPlan, StudyPlanArticleRead, StudyPlanBlock, StudyPlanBlockArticle,
StudyPlanBlockProgress,
)
from app.models.user import User
from app.services.quiz_builder import GenerateTestRequest, bank_query, create_saved_test
from app.utils.auth import get_current_user
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."""
plans = db.query(StudyPlan).filter(StudyPlan.is_published == 1).order_by(
StudyPlan.sort_order, StudyPlan.name).all()
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()}
@ -36,6 +68,7 @@ def list_study_plans(db: Session = Depends(get_db), current_user: User = Depends
"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),
@ -47,20 +80,24 @@ def list_study_plans(db: Session = Depends(get_db), current_user: User = Depends
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:
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],
}
@ -107,3 +144,240 @@ def start_block(block_id: int, mode: str = "learning", db: Session = Depends(get
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}
@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()

View file

@ -0,0 +1,182 @@
"""Editing study plans, and the reading attached to their blocks.
Disposable SQLite; no network or AI. The interesting cases are the ones where a
naive implementation leaves the data inconsistent: deleting a block out of the
middle, reordering through a unique constraint, and moving questions between
blocks of different plans.
"""
import unittest
import test_quiz_builder as fixtures
from app.models.article import Article
from app.models.study_plan import (
StudyPlan, StudyPlanArticleRead, StudyPlanBlock, StudyPlanBlockArticle,
)
from app.routers import study_plans
class StudyPlanEditingTests(unittest.TestCase):
def setUp(self):
self.bank = fixtures.BuilderTests()
self.bank.setUp()
self.client = self.bank.client
self.client.app.include_router(study_plans.router, prefix='/study-plans')
self.db = self.bank.db
self.db.add(StudyPlan(id=1, slug='prep-2025', name='PREP 2025', kind='set', is_published=1))
self.db.add(StudyPlan(id=2, slug='other-plan', name='Other', kind='set', is_published=1))
self.db.flush()
for position, title, qids in [(0, 'Block 1', [1, 2]), (1, 'Block 2', [5]), (2, 'Block 3', [])]:
self.db.add(StudyPlanBlock(plan_id=1, position=position, title=title, question_ids=qids))
self.db.add(StudyPlanBlock(id=90, plan_id=2, position=0, title='Elsewhere', question_ids=[6]))
self.db.add(Article(id=1, slug='asthma', title='Asthma', status='published',
sections=[], user_id=3))
self.db.add(Article(id=2, slug='draft-note', title='Draft note', status='draft',
sections=[], user_id=3))
self.db.commit()
self.blocks = {b.title: b.id for b in self.db.query(StudyPlanBlock).all()}
self.bank.user = self.bank.mod
def tearDown(self):
self.bank.tearDown()
def positions(self, plan_id=1):
return [(b.title, b.position) for b in self.db.query(StudyPlanBlock).filter_by(
plan_id=plan_id).order_by(StudyPlanBlock.position).all()]
# ── blocks ────────────────────────────────────────────────────────────────
def test_deleting_a_block_closes_the_gap_it_leaves(self):
response = self.client.delete(f"/study-plans/blocks/{self.blocks['Block 2']}")
self.assertEqual(response.status_code, 204, response.text)
self.db.expire_all()
# Positions are unique per plan, so a hole would collide with the next insert.
self.assertEqual(self.positions(), [('Block 1', 0), ('Block 3', 1)])
self.assertEqual(self.client.post('/study-plans/1/blocks',
json={'title': 'Block 4'}).status_code, 201)
def test_reordering_survives_the_unique_position_constraint(self):
order = [self.blocks['Block 3'], self.blocks['Block 1'], self.blocks['Block 2']]
response = self.client.post('/study-plans/1/blocks/order', json={'block_ids': order})
self.assertEqual(response.status_code, 200, response.text)
self.db.expire_all()
self.assertEqual(self.positions(), [('Block 3', 0), ('Block 1', 1), ('Block 2', 2)])
def test_a_partial_order_is_refused_rather_than_half_applied(self):
response = self.client.post('/study-plans/1/blocks/order',
json={'block_ids': [self.blocks['Block 1']]})
self.assertEqual(response.status_code, 400)
self.db.expire_all()
self.assertEqual(self.positions(), [('Block 1', 0), ('Block 2', 1), ('Block 3', 2)])
def test_questions_move_between_blocks_of_the_same_plan_only(self):
source, target = self.blocks['Block 1'], self.blocks['Block 2']
response = self.client.post(f'/study-plans/blocks/{source}/move',
json={'question_ids': [1], 'to_block_id': target})
self.assertEqual(response.status_code, 200, response.text)
self.db.expire_all()
self.assertEqual(self.db.get(StudyPlanBlock, source).question_ids, [2])
self.assertEqual(self.db.get(StudyPlanBlock, target).question_ids, [5, 1])
# A different plan is a different route through the bank.
self.assertEqual(self.client.post(f'/study-plans/blocks/{source}/move',
json={'question_ids': [2], 'to_block_id': 90}).status_code, 400)
def test_a_block_only_holds_questions_that_exist(self):
block = self.blocks['Block 3']
self.assertEqual(self.client.patch(f'/study-plans/blocks/{block}',
json={'question_ids': [1, 9999]}).status_code, 400)
self.db.expire_all()
self.assertEqual(self.db.get(StudyPlanBlock, block).question_ids, [])
# ── reading ───────────────────────────────────────────────────────────────
def test_reading_is_attached_once_and_appears_on_the_block(self):
block = self.blocks['Block 1']
link_id = self.client.post(f'/study-plans/blocks/{block}/articles',
json={'article_id': 1}).json()['link_id']
self.assertEqual(self.client.post(f'/study-plans/blocks/{block}/articles',
json={'article_id': 1}).status_code, 409)
plan = self.client.get('/study-plans/1').json()
first = next(b for b in plan['blocks'] if b['id'] == block)
self.assertEqual([a['title'] for a in first['articles']], ['Asthma'])
self.assertFalse(first['articles'][0]['read'])
self.assertEqual(first['articles'][0]['link_id'], link_id)
def test_marking_read_is_per_learner_and_reversible(self):
block = self.blocks['Block 1']
link_id = self.client.post(f'/study-plans/blocks/{block}/articles',
json={'article_id': 1}).json()['link_id']
self.bank.user = self.bank.owner
self.client.post(f'/study-plans/reading/{link_id}/read', params={'read': True})
read_by_owner = next(a for b in self.client.get('/study-plans/1').json()['blocks']
for a in b['articles'])
self.assertTrue(read_by_owner['read'])
# Another learner's progress is their own.
self.bank.user = self.bank.peer
self.assertFalse(next(a for b in self.client.get('/study-plans/1').json()['blocks']
for a in b['articles'])['read'])
self.bank.user = self.bank.owner
self.client.post(f'/study-plans/reading/{link_id}/read', params={'read': False})
self.assertFalse(next(a for b in self.client.get('/study-plans/1').json()['blocks']
for a in b['articles'])['read'])
self.assertEqual(self.db.query(StudyPlanArticleRead).count(), 0)
def test_a_draft_article_is_not_offered_to_a_learner_as_a_dead_link(self):
block = self.blocks['Block 1']
self.client.post(f'/study-plans/blocks/{block}/articles', json={'article_id': 2})
self.bank.user = self.bank.owner
titles = [a['title'] for b in self.client.get('/study-plans/1').json()['blocks']
for a in b['articles']]
self.assertEqual(titles, [])
self.bank.user = self.bank.mod
titles = [a['title'] for b in self.client.get('/study-plans/1').json()['blocks']
for a in b['articles']]
self.assertEqual(titles, ['Draft note'])
def test_detaching_reading_takes_the_progress_with_it(self):
block = self.blocks['Block 1']
link_id = self.client.post(f'/study-plans/blocks/{block}/articles',
json={'article_id': 1}).json()['link_id']
self.bank.user = self.bank.owner
self.client.post(f'/study-plans/reading/{link_id}/read', params={'read': True})
self.bank.user = self.bank.mod
self.assertEqual(self.client.delete(f'/study-plans/reading/{link_id}').status_code, 204)
self.assertEqual(self.db.query(StudyPlanBlockArticle).count(), 0)
self.assertEqual(self.db.query(StudyPlanArticleRead).count(), 0)
# ── plans and permissions ────────────────────────────────────────────────
def test_an_unpublished_plan_is_the_educators_alone(self):
self.client.patch('/study-plans/1', json={'is_published': False})
self.bank.user = self.bank.owner
self.assertEqual([p['id'] for p in self.client.get('/study-plans/').json()], [2])
self.assertEqual(self.client.get('/study-plans/1').status_code, 404)
self.bank.user = self.bank.mod
self.assertIn(1, [p['id'] for p in self.client.get('/study-plans/').json()])
self.assertEqual(self.client.get('/study-plans/1').status_code, 200)
def test_editing_is_moderator_only(self):
self.bank.user = self.bank.owner
block = self.blocks['Block 1']
for call in [
lambda: self.client.post('/study-plans/', json={'name': 'X', 'slug': 'x'}),
lambda: self.client.patch('/study-plans/1', json={'name': 'X'}),
lambda: self.client.delete('/study-plans/1'),
lambda: self.client.post('/study-plans/1/blocks', json={'title': 'B'}),
lambda: self.client.patch(f'/study-plans/blocks/{block}', json={'title': 'B'}),
lambda: self.client.delete(f'/study-plans/blocks/{block}'),
lambda: self.client.post(f'/study-plans/blocks/{block}/articles', json={'article_id': 1}),
]:
self.assertEqual(call().status_code, 403)
def test_a_slug_has_to_be_a_slug_and_has_to_be_free(self):
self.assertEqual(self.client.post('/study-plans/', json={
'name': 'New', 'slug': 'Not A Slug'}).status_code, 400)
self.assertEqual(self.client.post('/study-plans/', json={
'name': 'New', 'slug': 'prep-2025'}).status_code, 409)
self.assertEqual(self.client.post('/study-plans/', json={
'name': 'New', 'slug': 'prep-2026'}).status_code, 201)

View file

@ -42,10 +42,18 @@ Updated 2026-09-10.
## Content and editing
- [ ] **Admin can edit everything** — study plans (rename, reorder, add/remove
blocks, move questions between blocks) and attach articles to a block.
- [ ] **Study plan blocks carry articles**, not only questions: "Articles" with
*Mark as read*, then "Sessions" with Study/Exam mode.
- [x] **Study plans have a front end at all** — done 2026-09-10. 13 plans were
seeded with an API to serve them and no page that called it. `/study-plans`
lists them with progress in blocks; `/study-plans/:id` is one plan.
- [x] **Admin can edit study plans** — done 2026-09-10. Create (as a draft),
rename, publish/unpublish, delete; add, rename, reorder and remove blocks;
move questions between blocks of the same plan; attach and detach reading.
Editing is inline on the learner's own page, so there is no second layout
to keep in step.
- [x] **Study plan blocks carry articles** — done 2026-09-10. Each block shows
Articles with a reversible *Mark as read*, then Sessions with Study and
Exam mode. Reading progress is per learner and separate from
`article_views`: opening an article is not the claim that you finished it.
- [ ] **Admin settings page revamp** — currently ugly; needs restructuring.
- [x] **Image libraries** — done 2026-09-10. Libraries, per-library grants, tags
on the shared vocabulary, and MinIO behind a storage service.

View file

@ -33,6 +33,8 @@ const FlashcardsPage = lazy(() => import('./pages/FlashcardsPage'))
const ArticlesPage = lazy(() => import('./pages/ArticlesPage'))
const SearchPage = lazy(() => import('./pages/SearchPage'))
const MediaPage = lazy(() => import('./pages/MediaPage'))
const StudyPlansPage = lazy(() => import('./pages/StudyPlansPage'))
const StudyPlanPage = lazy(() => import('./pages/StudyPlanPage'))
const ArticlePage = lazy(() => import('./pages/ArticlesPage').then(m => ({ default: m.ArticlePage })))
const PublicQuizPage = lazy(() => import('./pages/PublicQuizPage'))
const FlashcardStudyPage = lazy(() => import('./pages/FlashcardStudyPage'))
@ -103,6 +105,8 @@ function AppRoutes() {
<Route path="/flashcards" element={<FlashcardsPage />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/media" element={<MediaPage />} />
<Route path="/study-plans" element={<StudyPlansPage />} />
<Route path="/study-plans/:id" element={<StudyPlanPage />} />
<Route path="/articles" element={<ArticlesPage />} />
<Route path="/articles/:id" element={<ArticlePage />} />
{/* Cross-references in article prose address a topic by slug, which

View file

@ -107,6 +107,7 @@ export default function Navbar({ onSignIn, onRegister }) {
{ to: '/question-bank', label: 'Question Bank' },
...(canManageQuestions ? [{ to: '/questions/manage', label: 'Manage Qs' },
{ to: '/media', label: 'Images' }] : []),
{ to: '/study-plans', label: 'Study plans' },
{ to: '/articles', label: 'Reading' },
{ to: '/flashcards', label: 'Cards' },
{ to: '/courses', label: 'Courses' },

View file

@ -0,0 +1,323 @@
import { useCallback, useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import api from '../api/client'
import { useAuth } from '../context/AuthContext'
import './StudyPlansPage.css'
const apiError = (err, fallback) => {
const detail = err?.response?.data?.detail
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) return detail.map(d => d?.msg).filter(Boolean).join('; ') || fallback
return fallback
}
/**
* One plan: its blocks, each with the reading that prepares you for it.
*
* Reading sits above the questions in every block because that is the order it
* is meant to be done in, and "Mark as read" is the learner's own claim the
* plan does not decide you have read something because you opened it.
*
* Editing is inline for moderators rather than a separate builder screen: the
* thing you are changing and the thing a learner sees are then the same object,
* and there is no second layout to keep in step.
*/
export default function StudyPlanPage() {
const { user } = useAuth()
const { id } = useParams()
const navigate = useNavigate()
const [plan, setPlan] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [editing, setEditing] = useState(false)
const [draftName, setDraftName] = useState('')
const [blockTitle, setBlockTitle] = useState('')
const [addingBlock, setAddingBlock] = useState(false)
const [editingBlock, setEditingBlock] = useState(null)
const [blockDraft, setBlockDraft] = useState('')
const [attachTo, setAttachTo] = useState(null)
const [articleQuery, setArticleQuery] = useState('')
const [articleHits, setArticleHits] = useState([])
const canEdit = !!user?.is_moderator
const load = useCallback(() => {
setLoading(true)
api.get(`/study-plans/${id}`)
.then(res => { setPlan(res.data); setDraftName(res.data.name) })
.catch(err => setError(apiError(err, 'Could not load this plan')))
.finally(() => setLoading(false))
}, [id])
useEffect(() => { load() }, [load])
const start = async (block, mode) => {
setBusy(true); setError('')
try {
const res = await api.post(`/study-plans/blocks/${block.id}/start`, null, { params: { mode } })
navigate(`/quizzes/${res.data.id}`)
} catch (err) { setError(apiError(err, 'Could not start this block')) }
finally { setBusy(false) }
}
const toggleRead = async (link) => {
// Optimistic: a tick that waits on the network feels broken, and the only
// cost of being wrong is a checkbox that flips back.
setPlan(prev => ({
...prev,
blocks: prev.blocks.map(b => ({
...b, articles: b.articles.map(a => a.link_id === link.link_id ? { ...a, read: !a.read } : a),
})),
}))
try {
await api.post(`/study-plans/reading/${link.link_id}/read`, null, { params: { read: !link.read } })
} catch (err) { setError(apiError(err, 'Could not save that')); load() }
}
const savePlan = async () => {
setBusy(true); setError('')
try {
await api.patch(`/study-plans/${id}`, { name: draftName.trim() })
setEditing(false); load()
} catch (err) { setError(apiError(err, 'Could not save the plan')) }
finally { setBusy(false) }
}
const setPublished = async (published) => {
setBusy(true); setError('')
try {
await api.patch(`/study-plans/${id}`, { is_published: published })
load()
} catch (err) { setError(apiError(err, 'Could not change that')) }
finally { setBusy(false) }
}
const addBlock = async () => {
if (!blockTitle.trim()) return
setBusy(true); setError('')
try {
await api.post(`/study-plans/${id}/blocks`, { title: blockTitle.trim(), question_ids: [] })
setBlockTitle(''); setAddingBlock(false); load()
} catch (err) { setError(apiError(err, 'Could not add that block')) }
finally { setBusy(false) }
}
const renameBlock = async (block) => {
setBusy(true); setError('')
try {
await api.patch(`/study-plans/blocks/${block.id}`, { title: blockDraft.trim() })
setEditingBlock(null); load()
} catch (err) { setError(apiError(err, 'Could not rename that block')) }
finally { setBusy(false) }
}
const removeBlock = async (block) => {
setBusy(true); setError('')
try {
await api.delete(`/study-plans/blocks/${block.id}`)
load()
} catch (err) { setError(apiError(err, 'Could not remove that block')) }
finally { setBusy(false) }
}
const moveBlock = async (index, delta) => {
const order = plan.blocks.map(b => b.id)
const target = index + delta
if (target < 0 || target >= order.length) return
;[order[index], order[target]] = [order[target], order[index]]
setBusy(true); setError('')
try {
await api.post(`/study-plans/${id}/blocks/order`, { block_ids: order })
load()
} catch (err) { setError(apiError(err, 'Could not reorder the blocks')) }
finally { setBusy(false) }
}
const searchArticles = async (text) => {
setArticleQuery(text)
if (text.trim().length < 2) { setArticleHits([]); return }
try {
const res = await api.get('/articles/', { params: { q: text.trim() } })
setArticleHits((res.data || []).slice(0, 8))
} catch { setArticleHits([]) }
}
const attachArticle = async (block, article) => {
setBusy(true); setError('')
try {
await api.post(`/study-plans/blocks/${block.id}/articles`, { article_id: article.id })
setAttachTo(null); setArticleQuery(''); setArticleHits([]); load()
} catch (err) { setError(apiError(err, 'Could not attach that article')) }
finally { setBusy(false) }
}
const detachArticle = async (link) => {
setBusy(true); setError('')
try {
await api.delete(`/study-plans/reading/${link.link_id}`)
load()
} catch (err) { setError(apiError(err, 'Could not remove that reading')) }
finally { setBusy(false) }
}
if (loading) return <div className="loading"><div className="spinner" /></div>
if (!plan) return <div className="plans-empty">{error || 'Plan not found.'}</div>
return (
<div className="plans-page">
<nav className="breadcrumbs" aria-label="Breadcrumb">
<Link to="/study-plans">Study plans</Link><span> / {plan.name}</span>
</nav>
<div className="plans-header">
<div>
{editing ? (
<div className="plans-create">
<input value={draftName} autoFocus aria-label="Plan name"
onChange={e => setDraftName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') savePlan() }} />
<button className="btn btn-primary btn-sm" disabled={busy} onClick={savePlan}>Save</button>
<button className="btn btn-secondary btn-sm" onClick={() => setEditing(false)}>Cancel</button>
</div>
) : (
<h1>{plan.name} {!plan.is_published && <span className="plan-tag is-draft">draft</span>}</h1>
)}
{plan.description && <p>{plan.description}</p>}
</div>
{canEdit && !editing && (
<div className="plans-header-actions">
<button className="btn btn-secondary btn-sm" onClick={() => setEditing(true)}>Rename</button>
<button className="btn btn-secondary btn-sm" disabled={busy}
onClick={() => setPublished(!plan.is_published)}>
{plan.is_published ? 'Unpublish' : 'Publish'}
</button>
<button className="btn btn-primary btn-sm" onClick={() => setAddingBlock(v => !v)}>Add block</button>
</div>
)}
</div>
{addingBlock && (
<div className="plans-create">
<input value={blockTitle} autoFocus placeholder="Block title, e.g. Block 7" aria-label="New block title"
onChange={e => setBlockTitle(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') addBlock() }} />
<button className="btn btn-primary btn-sm" disabled={busy} onClick={addBlock}>Add</button>
<button className="btn btn-secondary btn-sm" onClick={() => setAddingBlock(false)}>Cancel</button>
</div>
)}
{error && <p className="plans-error" role="alert">{error}</p>}
{plan.blocks.length === 0 ? (
<div className="plans-empty">This plan has no blocks yet.</div>
) : (
<ol className="block-list">
{plan.blocks.map((block, index) => (
<li key={block.id} className={`block${block.completed ? ' is-done' : ''}`}>
<div className="block-head">
{editingBlock === block.id ? (
<div className="plans-create">
<input value={blockDraft} autoFocus aria-label={`Title for ${block.title}`}
onChange={e => setBlockDraft(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') renameBlock(block) }} />
<button className="btn btn-primary btn-sm" disabled={busy} onClick={() => renameBlock(block)}>Save</button>
<button className="btn btn-secondary btn-sm" onClick={() => setEditingBlock(null)}>Cancel</button>
</div>
) : (
<>
<h2>{block.title}</h2>
<span className="block-meta">
{block.question_count} question{block.question_count === 1 ? '' : 's'}
{block.completed && ' · done'}
</span>
{canEdit && (
<span className="block-admin">
<button className="btn btn-secondary btn-sm" aria-label={`Move ${block.title} up`}
disabled={busy || index === 0} onClick={() => moveBlock(index, -1)}></button>
<button className="btn btn-secondary btn-sm" aria-label={`Move ${block.title} down`}
disabled={busy || index === plan.blocks.length - 1} onClick={() => moveBlock(index, 1)}></button>
<button className="btn btn-secondary btn-sm" aria-label={`Rename ${block.title}`}
onClick={() => { setEditingBlock(block.id); setBlockDraft(block.title) }}>Rename</button>
<button className="btn btn-secondary btn-sm" aria-label={`Remove ${block.title}`}
disabled={busy} onClick={() => removeBlock(block)}>Remove</button>
</span>
)}
</>
)}
</div>
{/* Reading first: it is the order the block is meant to be done in. */}
{(block.articles.length > 0 || canEdit) && (
<div className="block-reading">
<h3>Articles</h3>
{block.articles.length === 0 && <p className="block-empty">No reading attached.</p>}
<ul>
{block.articles.map(link => (
<li key={link.link_id}>
<label className="block-read">
<input type="checkbox" checked={link.read}
aria-label={`Mark ${link.title} as read`}
onChange={() => toggleRead(link)} />
<Link to={`/articles/${link.article_id}`}>{link.title}</Link>
{link.status !== 'published' && <span className="plan-tag is-draft">draft</span>}
</label>
{canEdit && (
<button className="btn btn-secondary btn-sm"
aria-label={`Remove ${link.title} from ${block.title}`}
disabled={busy} onClick={() => detachArticle(link)}>Remove</button>
)}
</li>
))}
</ul>
{canEdit && (attachTo === block.id ? (
<div className="block-attach">
<input value={articleQuery} autoFocus placeholder="Search articles…"
aria-label={`Search articles to add to ${block.title}`}
onChange={e => searchArticles(e.target.value)} />
{articleHits.length > 0 && (
<ul className="block-attach-hits">
{articleHits.map(article => (
<li key={article.id}>
<button type="button" onClick={() => attachArticle(block, article)}>
{article.title}
</button>
</li>
))}
</ul>
)}
<button className="btn btn-secondary btn-sm" onClick={() => setAttachTo(null)}>Cancel</button>
</div>
) : (
<button className="btn btn-secondary btn-sm"
onClick={() => { setAttachTo(block.id); setArticleQuery(''); setArticleHits([]) }}>
+ Add reading
</button>
))}
</div>
)}
<div className="block-sessions">
<h3>Sessions</h3>
{block.quiz_id ? (
<Link className="btn btn-primary btn-sm" to={`/quizzes/${block.quiz_id}`}>
{block.completed ? 'Review this block' : 'Continue this block'}
</Link>
) : block.question_count === 0 ? (
<p className="block-empty">No questions in this block yet.</p>
) : (
<div className="block-start">
<button className="btn btn-primary btn-sm" disabled={busy}
onClick={() => start(block, 'learning')}>Study mode</button>
<button className="btn btn-secondary btn-sm" disabled={busy}
onClick={() => start(block, 'timed')}>Exam mode</button>
</div>
)}
</div>
</li>
))}
</ol>
)}
</div>
)
}

View file

@ -0,0 +1,174 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import StudyPlansPage from './StudyPlansPage'
import StudyPlanPage from './StudyPlanPage'
import api from '../api/client'
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() } }))
let currentUser = { id: 1, name: 'Learner', is_moderator: false }
vi.mock('../context/AuthContext', () => ({ useAuth: () => ({ user: currentUser }) }))
const plans = [
{ id: 1, slug: 'prep-2025', name: 'PREP 2025', kind: 'set', exam_name: 'Pediatrics Boards',
is_published: true, block_count: 4, question_count: 200, blocks_completed: 1 },
{ id: 13, slug: 'prep-mixed', name: 'PREP Mixed', kind: 'mixed', exam_name: null,
is_published: true, block_count: 1, question_count: 300, blocks_completed: 0 },
]
const plan = {
id: 1, slug: 'prep-2025', name: 'PREP 2025', description: null, kind: 'set', is_published: true,
blocks: [
{ id: 10, position: 0, title: 'Block 1', question_count: 50, quiz_id: 77, completed: true,
articles: [{ link_id: 100, article_id: 5, slug: 'asthma', title: 'Asthma', status: 'published', read: true }] },
{ id: 11, position: 1, title: 'Block 2', question_count: 50, quiz_id: null, completed: false,
articles: [{ link_id: 101, article_id: 6, slug: 'croup', title: 'Croup', status: 'published', read: false }] },
{ id: 12, position: 2, title: 'Block 3', question_count: 0, quiz_id: null, completed: false, articles: [] },
],
}
const mockApi = (detail = plan) => api.get.mockImplementation(url => {
if (url === '/study-plans/') return Promise.resolve({ data: plans })
if (url === '/study-plans/1') return Promise.resolve({ data: detail })
return Promise.resolve({ data: [] })
})
const mountList = () => render(
<MemoryRouter initialEntries={['/study-plans']}>
<Routes><Route path="/study-plans" element={<StudyPlansPage />} /></Routes>
</MemoryRouter>)
const mountPlan = () => render(
<MemoryRouter initialEntries={['/study-plans/1']}>
<Routes><Route path="/study-plans/:id" element={<StudyPlanPage />} /></Routes>
</MemoryRouter>)
describe('study plans', () => {
beforeEach(() => {
vi.clearAllMocks()
currentUser = { id: 1, name: 'Learner', is_moderator: false }
mockApi()
})
it('states progress in blocks, which is something you can act on', async () => {
mountList()
const card = (await screen.findByText('PREP 2025')).closest('.plan-card')
expect(within(card).getByText('1 of 4 blocks done')).toBeInTheDocument()
expect(within(card).getByText(/4 blocks · 200 questions/)).toBeInTheDocument()
})
it('puts reading above the questions it prepares you for', async () => {
mountPlan()
const block = (await screen.findByText('Block 2')).closest('.block')
const headings = [...block.querySelectorAll('h3')].map(h => h.textContent)
expect(headings).toEqual(['Articles', 'Sessions'])
})
it('marks reading as read, and lets that be taken back', async () => {
mountPlan()
await screen.findByText('Block 2')
api.post.mockResolvedValue({ data: {} })
const croup = screen.getByRole('checkbox', { name: 'Mark Croup as read' })
expect(croup).not.toBeChecked()
await userEvent.click(croup)
await waitFor(() => expect(api.post).toHaveBeenCalledWith(
'/study-plans/reading/101/read', null, { params: { read: true } }))
expect(croup).toBeChecked()
// Reversible: a learner who ticks the wrong row fixes it themselves.
await userEvent.click(screen.getByRole('checkbox', { name: 'Mark Asthma as read' }))
await waitFor(() => expect(api.post).toHaveBeenCalledWith(
'/study-plans/reading/100/read', null, { params: { read: false } }))
})
it('offers both modes on a fresh block and continues one already started', async () => {
mountPlan()
const started = (await screen.findByText('Block 1')).closest('.block')
expect(within(started).getByRole('link', { name: 'Review this block' })).toHaveAttribute('href', '/quizzes/77')
const fresh = screen.getByText('Block 2').closest('.block')
api.post.mockResolvedValue({ data: { id: 91 } })
await userEvent.click(within(fresh).getByRole('button', { name: 'Study mode' }))
await waitFor(() => expect(api.post).toHaveBeenCalledWith(
'/study-plans/blocks/11/start', null, { params: { mode: 'learning' } }))
})
it('says a block is empty rather than offering a test with nothing in it', async () => {
mountPlan()
const empty = (await screen.findByText('Block 3')).closest('.block')
expect(within(empty).getByText('No questions in this block yet.')).toBeInTheDocument()
expect(within(empty).queryByRole('button', { name: 'Study mode' })).not.toBeInTheDocument()
})
it('keeps editing out of a learner\'s way', async () => {
mountPlan()
await screen.findByText('Block 1')
expect(screen.queryByRole('button', { name: 'Add block' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Rename Block 1' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '+ Add reading' })).not.toBeInTheDocument()
})
})
describe('study plans, as an educator', () => {
beforeEach(() => {
vi.clearAllMocks()
currentUser = { id: 9, name: 'Mod', is_moderator: true }
mockApi()
})
it('creates a plan as a draft, because an empty plan is not for a learner', async () => {
mountList()
await screen.findByText('PREP 2025')
api.post.mockResolvedValue({ data: { id: 20 } })
await userEvent.click(screen.getByRole('button', { name: 'New plan' }))
await userEvent.type(screen.getByLabelText('New plan name'), 'PREP 2026')
await userEvent.click(screen.getByRole('button', { name: 'Create' }))
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/study-plans/', {
name: 'PREP 2026', slug: 'prep-2026', kind: 'set', is_published: false,
}))
})
it('reorders blocks by sending the whole order', async () => {
mountPlan()
await screen.findByText('Block 1')
api.post.mockResolvedValue({ data: {} })
await userEvent.click(screen.getByRole('button', { name: 'Move Block 2 up' }))
await waitFor(() => expect(api.post).toHaveBeenCalledWith(
'/study-plans/1/blocks/order', { block_ids: [11, 10, 12] }))
})
it('cannot move the first block up or the last block down', async () => {
mountPlan()
await screen.findByText('Block 1')
expect(screen.getByRole('button', { name: 'Move Block 1 up' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Move Block 3 down' })).toBeDisabled()
})
it('attaches reading found by searching, not by id', async () => {
mountPlan()
await screen.findByText('Block 3')
api.get.mockImplementation(url => {
if (url === '/study-plans/1') return Promise.resolve({ data: plan })
if (url === '/articles/') return Promise.resolve({ data: [{ id: 8, title: 'Bronchiolitis' }] })
return Promise.resolve({ data: [] })
})
api.post.mockResolvedValue({ data: {} })
const block = screen.getByText('Block 3').closest('.block')
await userEvent.click(within(block).getByRole('button', { name: '+ Add reading' }))
await userEvent.type(screen.getByLabelText('Search articles to add to Block 3'), 'bronch')
await userEvent.click(await screen.findByRole('button', { name: 'Bronchiolitis' }))
await waitFor(() => expect(api.post).toHaveBeenCalledWith(
'/study-plans/blocks/12/articles', { article_id: 8 }))
})
it('publishes and unpublishes a plan', async () => {
mountPlan()
await screen.findByText('Block 1')
api.patch.mockResolvedValue({ data: {} })
await userEvent.click(screen.getByRole('button', { name: 'Unpublish' }))
await waitFor(() => expect(api.patch).toHaveBeenCalledWith('/study-plans/1', { is_published: false }))
})
})

View file

@ -0,0 +1,84 @@
/* Study plans: the list, and one plan's blocks. */
.plans-page { max-width: 860px; margin: 0 auto; padding-bottom: 48px; }
.plans-header { display: flex; justify-content: space-between; align-items: flex-end; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
.plans-header h1 { margin: 0 0 4px; font-size: 1.35rem; }
.plans-header p { margin: 0; color: var(--text-muted); font-size: 0.87rem; }
.plans-header-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.plans-create { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 14px; }
.plans-create input {
flex: 1; min-width: 180px; padding: 8px 12px; border: 1px solid var(--border);
border-radius: 8px; background: var(--input-bg); color: var(--text); font-size: 0.88rem;
}
.plans-hint { font-size: 0.78rem; color: var(--text-muted); }
.plans-error { color: var(--wrong-fg); font-size: 0.85rem; }
.plans-empty { background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; padding: 32px; text-align: center; color: var(--text-muted); }
.plans-grid { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); }
.plan-card {
display: flex; flex-direction: column; gap: 6px; height: 100%; padding: 14px 16px;
background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px;
text-decoration: none; color: inherit;
}
.plan-card:hover { border-color: var(--primary); }
.plan-card-head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.plan-card-head h2 { margin: 0; font-size: 1rem; }
.plan-card-desc { margin: 0; font-size: 0.83rem; color: var(--text-muted); }
.plan-card-meta { margin: 0; font-size: 0.78rem; color: var(--text-subtle); }
.plan-tag {
font-size: 0.63rem; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase;
padding: 1px 7px; border-radius: 10px; background: var(--option-sel-bg); color: var(--primary);
}
.plan-tag.is-draft { background: #fef3c7; color: #92400e; }
.plan-progress { margin-top: auto; padding-top: 8px; }
.plan-progress-bar { height: 5px; border-radius: 3px; background: var(--border); overflow: hidden; }
.plan-progress-bar span { display: block; height: 100%; background: var(--primary); }
/* Blocks, not a percentage: "3 of 6 blocks" is something you can act on. */
.plan-progress-text { display: block; margin-top: 4px; font-size: 0.75rem; color: var(--text-subtle); }
.block-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
.block { background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; }
.block.is-done { border-left: 3px solid var(--primary); }
.block-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
.block-head h2 { margin: 0; font-size: 1rem; }
.block-meta { font-size: 0.78rem; color: var(--text-subtle); }
.block-admin { margin-left: auto; display: flex; gap: 5px; flex-wrap: wrap; }
.block-reading, .block-sessions { padding-top: 10px; border-top: 1px solid var(--border); margin-top: 10px; }
.block-reading h3, .block-sessions h3 {
margin: 0 0 6px; font-size: 0.7rem; font-weight: 700; letter-spacing: 0.06em;
text-transform: uppercase; color: var(--text-subtle);
}
.block-reading ul { list-style: none; margin: 0 0 8px; padding: 0; display: flex; flex-direction: column; gap: 4px; }
.block-reading li { display: flex; align-items: center; gap: 8px; min-height: 36px; }
.block-read { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; cursor: pointer; font-size: 0.88rem; }
.block-read input { width: 17px; height: 17px; flex-shrink: 0; }
.block-read a { color: var(--text); text-decoration: none; overflow-wrap: anywhere; }
.block-read a:hover { color: var(--primary); }
.block-read input:checked ~ a { color: var(--text-muted); text-decoration: line-through; }
.block-empty { margin: 0 0 8px; font-size: 0.83rem; color: var(--text-muted); }
.block-attach { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
.block-attach input {
padding: 7px 11px; border: 1px solid var(--border); border-radius: 8px;
background: var(--input-bg); color: var(--text); font-size: 0.86rem;
}
.block-attach-hits { list-style: none; margin: 0; padding: 0; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.block-attach-hits button {
width: 100%; min-height: 38px; padding: 8px 11px; background: none; border: 0;
border-bottom: 1px solid var(--border); font: inherit; font-size: 0.85rem;
text-align: left; color: var(--text); cursor: pointer;
}
.block-attach-hits li:last-child button { border-bottom: 0; }
.block-attach-hits button:hover { background: var(--bg); color: var(--primary); }
.block-start { display: flex; gap: 8px; flex-wrap: wrap; }
@media (max-width: 640px) {
.plans-header-actions { width: 100%; }
.plans-header-actions .btn { flex: 1; }
.block-admin { margin-left: 0; width: 100%; }
.block-start .btn { flex: 1; }
}

View file

@ -0,0 +1,116 @@
import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../api/client'
import { useAuth } from '../context/AuthContext'
import './StudyPlansPage.css'
const apiError = (err, fallback) => {
const detail = err?.response?.data?.detail
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) return detail.map(d => d?.msg).filter(Boolean).join('; ') || fallback
return fallback
}
const slugify = (name) => name.toLowerCase().trim()
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 120)
/**
* The plans a learner can work through.
*
* Progress is stated in blocks rather than a percentage: "3 of 6 blocks" is a
* thing you can act on, where "50%" only tells you how you feel about it.
*/
export default function StudyPlansPage() {
const { user } = useAuth()
const [plans, setPlans] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [creating, setCreating] = useState(false)
const [name, setName] = useState('')
const [busy, setBusy] = useState(false)
const load = useCallback(() => {
setLoading(true)
api.get('/study-plans/')
.then(res => setPlans(res.data || []))
.catch(err => setError(apiError(err, 'Could not load study plans')))
.finally(() => setLoading(false))
}, [])
useEffect(() => { load() }, [load])
const create = async () => {
if (!name.trim()) return
setBusy(true); setError('')
try {
await api.post('/study-plans/', {
name: name.trim(), slug: slugify(name), kind: 'set', is_published: false,
})
setName(''); setCreating(false); load()
} catch (err) { setError(apiError(err, 'Could not create that plan')) }
finally { setBusy(false) }
}
return (
<div className="plans-page">
<div className="plans-header">
<div>
<h1>Study plans</h1>
<p>Worked through a block at a time read first, then sit the questions.</p>
</div>
{user?.is_moderator && (
<button className="btn btn-primary" onClick={() => setCreating(v => !v)}>New plan</button>
)}
</div>
{creating && (
<div className="plans-create">
<input value={name} autoFocus placeholder="Plan name, e.g. PREP 2026" aria-label="New plan name"
onChange={e => setName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') create() }} />
{/* Created unpublished: a plan with no blocks is not something to
put in front of a learner. */}
<span className="plans-hint">Starts as a draft until you add blocks.</span>
<button className="btn btn-primary btn-sm" disabled={busy} onClick={create}>Create</button>
<button className="btn btn-secondary btn-sm" onClick={() => setCreating(false)}>Cancel</button>
</div>
)}
{error && <p className="plans-error" role="alert">{error}</p>}
{loading ? <div className="loading"><div className="spinner" /></div>
: plans.length === 0 ? (
<div className="plans-empty">No study plans yet.</div>
) : (
<ul className="plans-grid">
{plans.map(plan => (
<li key={plan.id}>
<Link className="plan-card" to={`/study-plans/${plan.id}`}>
<div className="plan-card-head">
<h2>{plan.name}</h2>
{plan.kind === 'mixed' && <span className="plan-tag">mixed</span>}
{!plan.is_published && <span className="plan-tag is-draft">draft</span>}
</div>
{plan.description && <p className="plan-card-desc">{plan.description}</p>}
<p className="plan-card-meta">
{plan.block_count} block{plan.block_count === 1 ? '' : 's'} · {plan.question_count} questions
{plan.exam_name && ` · ${plan.exam_name}`}
</p>
{plan.block_count > 0 && (
<div className="plan-progress">
<div className="plan-progress-bar">
<span style={{ width: `${Math.round(100 * plan.blocks_completed / plan.block_count)}%` }} />
</div>
<span className="plan-progress-text">
{plan.blocks_completed} of {plan.block_count} blocks done
</span>
</div>
)}
</Link>
</li>
))}
</ul>
)}
</div>
)
}