pdf-quiz-generator/backend/alembic/versions/z8f9a0b1c2d3_study_plans.py
Daniel 8613fd1e5e 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
2026-09-10 02:25:29 +02:00

59 lines
2.1 KiB
Python

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