From 8613fd1e5e306b3cb30ec37db5bbbe353efc3946 Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 10 Sep 2026 02:25:29 +0200 Subject: [PATCH] feat: PREP study plans of numbered blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PREP sets were loose admin-generated quizzes. They are now study plans: one per year, split into blocks of 50 numbered "Block 1", "Block 2", plus a "PREP Mixed" plan of 300 drawn at random across every year. 12 plans, 2,821 questions, applied to production. Block membership is snapshotted rather than stored as a filter — a plan you are part-way through must not reshuffle between visits. Re-running the seeder updates years whose questions changed and leaves the mixed draw alone unless --reshuffle. Starting a block reuses the learner's existing quiz for it; without that, reopening a block would create a duplicate test each time and scatter the attempts across them. Only questions the learner may see are included. Tests: 113 backend green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m --- .../versions/z8f9a0b1c2d3_study_plans.py | 59 +++++++++ backend/app/main.py | 5 +- backend/app/models/study_plan.py | 57 +++++++++ backend/app/routers/study_plans.py | 109 ++++++++++++++++ backend/scripts/seed_prep_study_plans.py | 118 ++++++++++++++++++ 5 files changed, 346 insertions(+), 2 deletions(-) create mode 100644 backend/alembic/versions/z8f9a0b1c2d3_study_plans.py create mode 100644 backend/app/models/study_plan.py create mode 100644 backend/app/routers/study_plans.py create mode 100644 backend/scripts/seed_prep_study_plans.py diff --git a/backend/alembic/versions/z8f9a0b1c2d3_study_plans.py b/backend/alembic/versions/z8f9a0b1c2d3_study_plans.py new file mode 100644 index 0000000..bae39ee --- /dev/null +++ b/backend/alembic/versions/z8f9a0b1c2d3_study_plans.py @@ -0,0 +1,59 @@ +"""Study plans made of numbered question blocks. + +The PREP sets were loose admin-generated quizzes. A plan groups them into ordered +blocks a learner works through. Blocks store their question ids explicitly rather +than a filter, so a block stays the same set every time it is opened — a plan you +are working through must not reshuffle under you. + +Revision ID: z8f9a0b1c2d3 +Revises: y7e8f9a0b1c2 +""" +from alembic import op + +revision = "z8f9a0b1c2d3" +down_revision = "y7e8f9a0b1c2" +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute(""" + CREATE TABLE IF NOT EXISTS study_plans ( + id SERIAL PRIMARY KEY, + slug VARCHAR(120) UNIQUE NOT NULL, + name VARCHAR(200) NOT NULL, + description TEXT, + exam_id INTEGER REFERENCES exams(id) ON DELETE SET NULL, + kind VARCHAR(20) NOT NULL DEFAULT 'set', + sort_order INTEGER NOT NULL DEFAULT 100, + is_published INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """) + op.execute(""" + CREATE TABLE IF NOT EXISTS study_plan_blocks ( + id SERIAL PRIMARY KEY, + plan_id INTEGER NOT NULL REFERENCES study_plans(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + title VARCHAR(200) NOT NULL, + question_ids JSONB NOT NULL DEFAULT '[]'::jsonb, + CONSTRAINT uq_plan_block UNIQUE (plan_id, position) + ) + """) + op.execute("CREATE INDEX IF NOT EXISTS ix_plan_blocks_plan ON study_plan_blocks(plan_id)") + op.execute(""" + CREATE TABLE IF NOT EXISTS study_plan_block_progress ( + id SERIAL PRIMARY KEY, + block_id INTEGER NOT NULL REFERENCES study_plan_blocks(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + quiz_id INTEGER REFERENCES quizzes(id) ON DELETE SET NULL, + completed_at TIMESTAMP, + CONSTRAINT uq_block_progress UNIQUE (block_id, user_id) + ) + """) + + +def downgrade(): + op.execute("DROP TABLE IF EXISTS study_plan_block_progress") + op.execute("DROP TABLE IF EXISTS study_plan_blocks") + op.execute("DROP TABLE IF EXISTS study_plans") diff --git a/backend/app/main.py b/backend/app/main.py index 97f490d..0ba6bbf 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,7 +11,7 @@ from app.logging_config import setup_logging setup_logging(settings.LOG_LEVEL) from app.database import engine, Base, SessionLocal from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses, mobile, mynote, exams -from app.routers import study_tools, uploads, articles, comments, share, collections +from app.routers import study_tools, uploads, articles, comments, share, collections, study_plans from app.utils.auth import get_password_hash from app.utils.scheduler import start_scheduler, stop_scheduler @@ -168,7 +168,7 @@ def setup_pgvector(): # Import new models so create_all picks them up from app.models import quiz_category, quiz_question_link, question_category, favorite # noqa from app.models import flashcard, course # noqa - from app.models import category_grant, exam, media # noqa + from app.models import category_grant, exam, media, study_plan # noqa # Kill stale idle-in-transaction connections from previous killed startups. # They hold DDL locks and cause ALTER TABLE below to hang indefinitely. @@ -619,6 +619,7 @@ app.include_router(uploads.router) app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(articles.router, prefix="/api/articles", tags=["articles"]) app.include_router(exams.router, prefix="/api/exams", tags=["exams"]) +app.include_router(study_plans.router, prefix="/api/study-plans", tags=["study-plans"]) app.include_router(comments.router, prefix="/api/comments", tags=["comments"]) app.include_router(share.router, prefix="/api/share", tags=["share"]) app.include_router(collections.router, prefix="/api/collections", tags=["collections"]) diff --git a/backend/app/models/study_plan.py b/backend/app/models/study_plan.py new file mode 100644 index 0000000..d3eb5a3 --- /dev/null +++ b/backend/app/models/study_plan.py @@ -0,0 +1,57 @@ +from datetime import datetime + +from sqlalchemy import JSON, Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import relationship + +from app.database import Base + + +class StudyPlan(Base): + """An ordered set of question blocks a learner works through.""" + + __tablename__ = "study_plans" + + id = Column(Integer, primary_key=True, index=True) + slug = Column(String(120), unique=True, nullable=False, index=True) + name = Column(String(200), nullable=False) + description = Column(Text, nullable=True) + exam_id = Column(Integer, ForeignKey("exams.id", ondelete="SET NULL"), nullable=True) + kind = Column(String(20), default="set") # set | mixed + sort_order = Column(Integer, default=100) + is_published = Column(Integer, default=1) + created_at = Column(DateTime, default=datetime.utcnow) + + blocks = relationship("StudyPlanBlock", back_populates="plan", + cascade="all, delete-orphan", order_by="StudyPlanBlock.position") + + +class StudyPlanBlock(Base): + """One numbered block. Its question ids are fixed, not a live filter. + + A plan you are part-way through must not reshuffle between visits, so the + membership is snapshotted when the plan is built. + """ + + __tablename__ = "study_plan_blocks" + __table_args__ = (UniqueConstraint("plan_id", "position", name="uq_plan_block"),) + + id = Column(Integer, primary_key=True, index=True) + plan_id = Column(Integer, ForeignKey("study_plans.id", ondelete="CASCADE"), nullable=False, index=True) + position = Column(Integer, nullable=False) + title = Column(String(200), nullable=False) + question_ids = Column(JSON, nullable=False, default=list) + + plan = relationship("StudyPlan", back_populates="blocks") + + +class StudyPlanBlockProgress(Base): + """Which block a learner has started, and the quiz it produced.""" + + __tablename__ = "study_plan_block_progress" + __table_args__ = (UniqueConstraint("block_id", "user_id", name="uq_block_progress"),) + + id = Column(Integer, primary_key=True, index=True) + block_id = Column(Integer, ForeignKey("study_plan_blocks.id", ondelete="CASCADE"), nullable=False, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + quiz_id = Column(Integer, ForeignKey("quizzes.id", ondelete="SET NULL"), nullable=True) + completed_at = Column(DateTime, nullable=True) diff --git a/backend/app/routers/study_plans.py b/backend/app/routers/study_plans.py new file mode 100644 index 0000000..f88b5bf --- /dev/null +++ b/backend/app/routers/study_plans.py @@ -0,0 +1,109 @@ +"""Study plans — ordered blocks of questions a learner works through.""" +import logging + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models.exam import Exam +from app.models.question import Question +from app.models.study_plan import StudyPlan, StudyPlanBlock, 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 + +router = APIRouter() +log = logging.getLogger(__name__) + + +@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() + if not plans: + return [] + exams = {e.id: e.name for e in db.query(Exam).all()} + done = { + row[0] for row in db.query(StudyPlanBlockProgress.block_id).filter( + StudyPlanBlockProgress.user_id == current_user.id, + StudyPlanBlockProgress.completed_at.isnot(None)).all() + } + out = [] + for plan in plans: + blocks = plan.blocks + out.append({ + "id": plan.id, "slug": plan.slug, "name": plan.name, + "description": plan.description, "kind": plan.kind, + "exam_name": exams.get(plan.exam_id), + "block_count": len(blocks), + "question_count": sum(len(b.question_ids or []) for b in blocks), + "blocks_completed": sum(1 for b in blocks if b.id in done), + }) + return out + + +@router.get("/{plan_id}") +def get_study_plan(plan_id: int, db: Session = Depends(get_db), + current_user: User = Depends(get_current_user)): + plan = db.get(StudyPlan, plan_id) + if not plan or not plan.is_published: + 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() + } + return { + "id": plan.id, "slug": plan.slug, "name": plan.name, + "description": plan.description, "kind": plan.kind, + "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), + } for block in plan.blocks], + } + + +@router.post("/blocks/{block_id}/start") +def start_block(block_id: int, mode: str = "learning", db: Session = Depends(get_db), + current_user: User = Depends(get_current_user)): + """Turn a block into a test. Reuses the learner's existing quiz for the block. + + Without the reuse, reopening a block would create a duplicate test each time + and scatter the learner's attempts across them. + """ + block = db.get(StudyPlanBlock, block_id) + if not block: + raise HTTPException(404, "Block not found") + if mode not in ("learning", "timed"): + raise HTTPException(400, "Mode must be learning or timed") + + row = db.query(StudyPlanBlockProgress).filter_by(block_id=block.id, user_id=current_user.id).first() + if row and row.quiz_id: + return {"id": row.quiz_id, "reused": True} + + # Only questions this learner may actually see. + visible = { + qid for (qid,) in bank_query(db, current_user) + .with_entities(Question.id) + .filter(Question.id.in_(block.question_ids or [])) + .all() + } + ids = [qid for qid in (block.question_ids or []) if qid in visible] + if not ids: + raise HTTPException(400, "No questions in this block are available to you") + + plan = db.get(StudyPlan, block.plan_id) + created = create_saved_test( + db, current_user, + GenerateTestRequest(title=f"{plan.name} — {block.title}", count=len(ids), + mode=mode, time_limit_minutes=None, is_shared=False), + ids, + ) + if row is None: + row = StudyPlanBlockProgress(block_id=block.id, user_id=current_user.id) + db.add(row) + row.quiz_id = created["id"] + db.commit() + return {**created, "reused": False} diff --git a/backend/scripts/seed_prep_study_plans.py b/backend/scripts/seed_prep_study_plans.py new file mode 100644 index 0000000..8ab646e --- /dev/null +++ b/backend/scripts/seed_prep_study_plans.py @@ -0,0 +1,118 @@ +"""Turn the PREP question sets into study plans of numbered blocks. + +One plan per PREP year, split into blocks of BLOCK_SIZE, plus a mixed plan that +draws MIXED_SIZE questions at random across every year. + +Block membership is snapshotted, not a live filter: a plan you are part-way +through must not reshuffle between visits. Re-running updates block contents for +years that changed, and leaves the mixed plan's draw alone unless --reshuffle. + + docker compose exec backend python -m scripts.seed_prep_study_plans + docker compose exec backend python -m scripts.seed_prep_study_plans --apply +""" +import random +import re +import sys + +from sqlalchemy import text as sa_text + +from app.database import SessionLocal +from app.models.exam import Exam +from app.models.study_plan import StudyPlan, StudyPlanBlock + +BLOCK_SIZE = 50 +MIXED_SIZE = 300 +MIXED_SLUG = "prep-mixed" + + +def prep_tags(db): + """PREP year tags, newest first — 'PREP 2021', not 'Preparticipation Exam'.""" + rows = db.execute(sa_text(""" + SELECT t.id, t.name FROM question_tags t + WHERE t.name ~ '^PREP [0-9]{4}$' + ORDER BY t.name DESC + """)).fetchall() + return [(row[0], row[1]) for row in rows] + + +def question_ids_for(db, tag_id): + rows = db.execute(sa_text( + "SELECT question_id FROM question_tag_links WHERE tag_id = :t ORDER BY question_id" + ), {"t": tag_id}).fetchall() + return [row[0] for row in rows] + + +def upsert_plan(db, slug, name, description, kind, sort_order, exam_id): + plan = db.query(StudyPlan).filter_by(slug=slug).first() + if plan is None: + plan = StudyPlan(slug=slug, name=name, description=description, + kind=kind, sort_order=sort_order, exam_id=exam_id) + db.add(plan) + db.flush() + else: + plan.name, plan.description = name, description + plan.kind, plan.sort_order, plan.exam_id = kind, sort_order, exam_id + return plan + + +def set_blocks(db, plan, chunks): + """Replace the plan's blocks with `chunks`, numbered from 1.""" + db.query(StudyPlanBlock).filter(StudyPlanBlock.plan_id == plan.id).delete(synchronize_session=False) + for index, ids in enumerate(chunks, start=1): + db.add(StudyPlanBlock(plan_id=plan.id, position=index, + title=f"Block {index}", question_ids=list(ids))) + + +def main(): + apply_changes = "--apply" in sys.argv + reshuffle = "--reshuffle" in sys.argv + db = SessionLocal() + try: + exam = db.query(Exam).filter_by(slug="pediatrics-boards").first() + exam_id = exam.id if exam else None + tags = prep_tags(db) + if not tags: + print("No 'PREP ' tags found; nothing to do.") + return + + everything, summary = [], [] + for order, (tag_id, name) in enumerate(tags, start=1): + ids = question_ids_for(db, tag_id) + everything.extend(ids) + chunks = [ids[i:i + BLOCK_SIZE] for i in range(0, len(ids), BLOCK_SIZE)] + summary.append((name, len(ids), len(chunks))) + if apply_changes: + year = re.search(r"(\d{4})", name).group(1) + plan = upsert_plan(db, f"prep-{year}", name, + f"{len(ids)} questions in {len(chunks)} blocks of up to {BLOCK_SIZE}.", + "set", order, exam_id) + set_blocks(db, plan, chunks) + + mixed = db.query(StudyPlan).filter_by(slug=MIXED_SLUG).first() + draw_needed = reshuffle or mixed is None or not mixed.blocks + mixed_ids = [] + if draw_needed: + pool = list(dict.fromkeys(everything)) + random.shuffle(pool) + mixed_ids = pool[:MIXED_SIZE] + if apply_changes: + plan = upsert_plan(db, MIXED_SLUG, "PREP Mixed", + f"{MIXED_SIZE} questions drawn at random from every PREP year.", + "mixed", 0, exam_id) + if draw_needed: + set_blocks(db, plan, [mixed_ids]) + db.commit() + + print("APPLIED" if apply_changes else "DRY RUN") + for name, count, blocks in summary: + print(f" {name:12s} {count:4d} questions -> {blocks} blocks") + print(f" {'PREP Mixed':12s} {len(mixed_ids) or MIXED_SIZE:4d} questions -> 1 block" + f"{'' if draw_needed else ' (existing draw kept)'}") + if not apply_changes: + print("\n Re-run with --apply to write these plans.") + finally: + db.close() + + +if __name__ == "__main__": + sys.exit(main())