diff --git a/backend/alembic/versions/f4a5b6c7d8e9_study_plan_articles.py b/backend/alembic/versions/f4a5b6c7d8e9_study_plan_articles.py
new file mode 100644
index 0000000..3795739
--- /dev/null
+++ b/backend/alembic/versions/f4a5b6c7d8e9_study_plan_articles.py
@@ -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")
diff --git a/backend/app/models/study_plan.py b/backend/app/models/study_plan.py
index d3eb5a3..0102892 100644
--- a/backend/app/models/study_plan.py
+++ b/backend/app/models/study_plan.py
@@ -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."""
diff --git a/backend/app/routers/study_plans.py b/backend/app/routers/study_plans.py
index f88b5bf..4246503 100644
--- a/backend/app/routers/study_plans.py
+++ b/backend/app/routers/study_plans.py
@@ -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()
diff --git a/backend/tests/test_study_plan_editing.py b/backend/tests/test_study_plan_editing.py
new file mode 100644
index 0000000..204fb87
--- /dev/null
+++ b/backend/tests/test_study_plan_editing.py
@@ -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)
diff --git a/docs/TODO.md b/docs/TODO.md
index 28fefbd..889d17d 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -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.
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 11b5f2e..e3a59b1 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -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() {
{plan.description}
} +{error}
} + + {plan.blocks.length === 0 ? ( +No reading attached.
} +No questions in this block yet.
+ ) : ( +Worked through a block at a time — read first, then sit the questions.
+{error}
} + + {loading ?{plan.description}
} ++ {plan.block_count} block{plan.block_count === 1 ? '' : 's'} · {plan.question_count} questions + {plan.exam_name && ` · ${plan.exam_name}`} +
+ {plan.block_count > 0 && ( +