"""Turn the year question sets into study plans of numbered blocks. One plan per year, split evenly into blocks of about 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_study_plans docker compose exec backend python -m scripts.seed_study_plans --apply """ import random import re import sys from math import ceil 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 = 40 MIXED_SIZE = 300 MIXED_SLUG = "mixed-review" # The imported material is tagged with the source programme's name; the plans # built from it are ours and are named for what they are. YEAR_NAME = "Board Review {year}" YEAR_SLUG = "board-review-{year}" def prep_tags(db): """Year tags, newest first. The tags still carry the source programme's name because that is what the imported material was labelled with; the plans built from them do not. """ rows = db.execute(sa_text(""" SELECT t.id, t.name FROM question_tags t WHERE t.name ~ '^(Board Review|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 even_chunks(ids, target=BLOCK_SIZE): """Split into ceil(n / target) blocks whose sizes differ by at most one. Plain slicing leaves a remainder — 202 questions became five blocks of 50 and one of 2 — and a block of two questions is not a study block. """ k = max(1, ceil(len(ids) / target)) base, extra = divmod(len(ids), k) out, i = [], 0 for n in range(k): size = base + (1 if n < extra else 0) out.append(ids[i:i + size]) i += size return out 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 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 = even_chunks(ids) summary.append((name, len(ids), len(chunks))) if apply_changes: year = re.search(r"(\d{4})", name).group(1) plan = upsert_plan(db, YEAR_SLUG.format(year=year), YEAR_NAME.format(year=year), f"{len(ids)} questions in {len(chunks)} blocks of about {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, "Mixed Review", f"{MIXED_SIZE} questions drawn at random from every 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: year = re.search(r"(\d{4})", name) shown = YEAR_NAME.format(year=year.group(1)) if year else name print(f" {shown:18s} {count:4d} questions -> {blocks} blocks") print(f" {'Mixed Review':18s} {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())