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:
parent
899ad5e879
commit
8613fd1e5e
5 changed files with 346 additions and 2 deletions
59
backend/alembic/versions/z8f9a0b1c2d3_study_plans.py
Normal file
59
backend/alembic/versions/z8f9a0b1c2d3_study_plans.py
Normal 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")
|
||||
|
|
@ -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"])
|
||||
|
|
|
|||
57
backend/app/models/study_plan.py
Normal file
57
backend/app/models/study_plan.py
Normal 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)
|
||||
109
backend/app/routers/study_plans.py
Normal file
109
backend/app/routers/study_plans.py
Normal 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}
|
||||
118
backend/scripts/seed_prep_study_plans.py
Normal file
118
backend/scripts/seed_prep_study_plans.py
Normal 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())
|
||||
Loading…
Reference in a new issue