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() { } /> } /> } /> + } /> + } /> } /> } /> {/* Cross-references in article prose address a topic by slug, which diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index 482308c..4afb168 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -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' }, diff --git a/frontend/src/pages/StudyPlanPage.jsx b/frontend/src/pages/StudyPlanPage.jsx new file mode 100644 index 0000000..f946303 --- /dev/null +++ b/frontend/src/pages/StudyPlanPage.jsx @@ -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
+ if (!plan) return
{error || 'Plan not found.'}
+ + return ( +
+ + +
+
+ {editing ? ( +
+ setDraftName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') savePlan() }} /> + + +
+ ) : ( +

{plan.name} {!plan.is_published && draft}

+ )} + {plan.description &&

{plan.description}

} +
+ {canEdit && !editing && ( +
+ + + +
+ )} +
+ + {addingBlock && ( +
+ setBlockTitle(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') addBlock() }} /> + + +
+ )} + + {error &&

{error}

} + + {plan.blocks.length === 0 ? ( +
This plan has no blocks yet.
+ ) : ( +
    + {plan.blocks.map((block, index) => ( +
  1. +
    + {editingBlock === block.id ? ( +
    + setBlockDraft(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') renameBlock(block) }} /> + + +
    + ) : ( + <> +

    {block.title}

    + + {block.question_count} question{block.question_count === 1 ? '' : 's'} + {block.completed && ' · done'} + + {canEdit && ( + + + + + + + )} + + )} +
    + + {/* Reading first: it is the order the block is meant to be done in. */} + {(block.articles.length > 0 || canEdit) && ( +
    +

    Articles

    + {block.articles.length === 0 &&

    No reading attached.

    } +
      + {block.articles.map(link => ( +
    • + + {canEdit && ( + + )} +
    • + ))} +
    + {canEdit && (attachTo === block.id ? ( +
    + searchArticles(e.target.value)} /> + {articleHits.length > 0 && ( +
      + {articleHits.map(article => ( +
    • + +
    • + ))} +
    + )} + +
    + ) : ( + + ))} +
    + )} + +
    +

    Sessions

    + {block.quiz_id ? ( + + {block.completed ? 'Review this block' : 'Continue this block'} + + ) : block.question_count === 0 ? ( +

    No questions in this block yet.

    + ) : ( +
    + + +
    + )} +
    +
  2. + ))} +
+ )} +
+ ) +} diff --git a/frontend/src/pages/StudyPlanPage.test.jsx b/frontend/src/pages/StudyPlanPage.test.jsx new file mode 100644 index 0000000..a2ffb8b --- /dev/null +++ b/frontend/src/pages/StudyPlanPage.test.jsx @@ -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( + + } /> + ) + +const mountPlan = () => render( + + } /> + ) + +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 })) + }) +}) diff --git a/frontend/src/pages/StudyPlansPage.css b/frontend/src/pages/StudyPlansPage.css new file mode 100644 index 0000000..92b35da --- /dev/null +++ b/frontend/src/pages/StudyPlansPage.css @@ -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; } +} diff --git a/frontend/src/pages/StudyPlansPage.jsx b/frontend/src/pages/StudyPlansPage.jsx new file mode 100644 index 0000000..a474b5e --- /dev/null +++ b/frontend/src/pages/StudyPlansPage.jsx @@ -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 ( +
+
+
+

Study plans

+

Worked through a block at a time — read first, then sit the questions.

+
+ {user?.is_moderator && ( + + )} +
+ + {creating && ( +
+ 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. */} + Starts as a draft until you add blocks. + + +
+ )} + + {error &&

{error}

} + + {loading ?
+ : plans.length === 0 ? ( +
No study plans yet.
+ ) : ( +
    + {plans.map(plan => ( +
  • + +
    +

    {plan.name}

    + {plan.kind === 'mixed' && mixed} + {!plan.is_published && draft} +
    + {plan.description &&

    {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 && ( +
    +
    + +
    + + {plan.blocks_completed} of {plan.block_count} blocks done + +
    + )} + +
  • + ))} +
+ )} +
+ ) +}