feat: PREP study plans of numbered blocks

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
This commit is contained in:
Daniel 2026-09-10 02:25:29 +02:00
parent 899ad5e879
commit 8613fd1e5e
5 changed files with 346 additions and 2 deletions

View file

@ -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")

View file

@ -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"])

View file

@ -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)

View file

@ -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}

View file

@ -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 <year>' 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())