Checkpoint React frontend migration

This commit is contained in:
Dwindi Ramadhana
2026-06-20 01:43:39 +07:00
parent ab86c254d1
commit b8e201b45f
173 changed files with 34116 additions and 782 deletions

1
backend/alembic/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration.

99
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,99 @@
"""
Alembic environment configuration for async PostgreSQL migrations.
Configures Alembic to work with SQLAlchemy async engine and models.
"""
import asyncio
import sys
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
# Import models and Base
sys.path.insert(0, ".")
from app.database import Base
from app.models import * # noqa: F401, F403
# Import settings for database URL
from app.core.config import get_settings
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Get settings and set database URL
settings = get_settings()
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,280 @@
"""initial schema
Revision ID: 20260331_000001
Revises:
Create Date: 2026-03-31 12:30:00
"""
from typing import Sequence, Union
from alembic import context, op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "20260331_000001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _table_exists(name: str) -> bool:
if context.is_offline_mode():
return False
return sa.inspect(op.get_bind()).has_table(name)
def upgrade() -> None:
if not _table_exists("websites"):
op.create_table(
"websites",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("site_url", sa.String(length=512), nullable=False),
sa.Column("site_name", sa.String(length=255), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_websites_site_url", "websites", ["site_url"], unique=True)
if not _table_exists("tryouts"):
op.create_table(
"tryouts",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("website_id", sa.Integer(), nullable=False),
sa.Column("tryout_id", sa.String(length=255), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", sa.String(length=1000), nullable=True),
sa.Column("scoring_mode", sa.String(length=50), nullable=False),
sa.Column("selection_mode", sa.String(length=50), nullable=False),
sa.Column("normalization_mode", sa.String(length=50), nullable=False),
sa.Column("min_sample_for_dynamic", sa.Integer(), nullable=False),
sa.Column("static_rataan", sa.Float(), nullable=False),
sa.Column("static_sb", sa.Float(), nullable=False),
sa.Column("ai_generation_enabled", sa.Boolean(), nullable=False),
sa.Column("hybrid_transition_slot", sa.Integer(), nullable=True),
sa.Column("min_calibration_sample", sa.Integer(), nullable=False),
sa.Column("theta_estimation_method", sa.String(length=50), nullable=False),
sa.Column("fallback_to_ctt_on_error", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["website_id"], ["websites.id"], ondelete="CASCADE", onupdate="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("website_id", "tryout_id", name="uq_tryouts_website_id_tryout_id"),
sa.CheckConstraint("min_sample_for_dynamic > 0", name="ck_min_sample_positive"),
sa.CheckConstraint("static_rataan > 0", name="ck_static_rataan_positive"),
sa.CheckConstraint("static_sb > 0", name="ck_static_sb_positive"),
sa.CheckConstraint("min_calibration_sample > 0", name="ck_min_calibration_positive"),
)
op.create_index("ix_tryouts_website_id", "tryouts", ["website_id"], unique=False)
op.create_index("ix_tryouts_tryout_id", "tryouts", ["tryout_id"], unique=False)
if not _table_exists("users"):
op.create_table(
"users",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("wp_user_id", sa.String(length=255), nullable=False),
sa.Column("website_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["website_id"], ["websites.id"], ondelete="CASCADE", onupdate="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("wp_user_id", "website_id", name="uq_users_wp_user_id_website_id"),
)
op.create_index("ix_users_wp_user_id", "users", ["wp_user_id"], unique=False)
op.create_index("ix_users_website_id", "users", ["website_id"], unique=False)
if not _table_exists("items"):
op.create_table(
"items",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("tryout_id", sa.String(length=255), nullable=False),
sa.Column("website_id", sa.Integer(), nullable=False),
sa.Column("slot", sa.Integer(), nullable=False),
sa.Column("level", sa.String(length=50), nullable=False),
sa.Column("stem", sa.Text(), nullable=False),
sa.Column("options", sa.JSON(), nullable=False),
sa.Column("correct_answer", sa.String(length=10), nullable=False),
sa.Column("explanation", sa.Text(), nullable=True),
sa.Column("ctt_p", sa.Float(), nullable=True),
sa.Column("ctt_bobot", sa.Float(), nullable=True),
sa.Column("ctt_category", sa.String(length=50), nullable=True),
sa.Column("irt_b", sa.Float(), nullable=True),
sa.Column("irt_se", sa.Float(), nullable=True),
sa.Column("calibrated", sa.Boolean(), nullable=False),
sa.Column("calibration_sample_size", sa.Integer(), nullable=False),
sa.Column("generated_by", sa.String(length=50), nullable=False),
sa.Column("ai_model", sa.String(length=255), nullable=True),
sa.Column("basis_item_id", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["website_id"], ["websites.id"], ondelete="CASCADE", onupdate="CASCADE"),
sa.ForeignKeyConstraint(["basis_item_id"], ["items.id"], ondelete="SET NULL", onupdate="CASCADE"),
sa.ForeignKeyConstraint(
["website_id", "tryout_id"],
["tryouts.website_id", "tryouts.tryout_id"],
name="fk_items_tryout",
ondelete="CASCADE",
onupdate="CASCADE",
),
sa.PrimaryKeyConstraint("id"),
sa.CheckConstraint("irt_b IS NULL OR (irt_b >= -3 AND irt_b <= 3)", name="ck_irt_b_range"),
sa.CheckConstraint("ctt_p IS NULL OR (ctt_p >= 0 AND ctt_p <= 1)", name="ck_ctt_p_range"),
sa.CheckConstraint("ctt_bobot IS NULL OR (ctt_bobot >= 0 AND ctt_bobot <= 1)", name="ck_ctt_bobot_range"),
sa.CheckConstraint("slot > 0", name="ck_slot_positive"),
)
op.create_index("ix_items_tryout_id", "items", ["tryout_id"], unique=False)
op.create_index("ix_items_website_id", "items", ["website_id"], unique=False)
op.create_index("ix_items_basis_item_id", "items", ["basis_item_id"], unique=False)
op.create_index("ix_items_calibrated", "items", ["calibrated"], unique=False)
op.create_index(
"ix_items_tryout_id_website_id_slot",
"items",
["tryout_id", "website_id", "slot", "level"],
unique=True,
)
if not _table_exists("sessions"):
op.create_table(
"sessions",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("session_id", sa.String(length=255), nullable=False),
sa.Column("wp_user_id", sa.String(length=255), nullable=False),
sa.Column("website_id", sa.Integer(), nullable=False),
sa.Column("tryout_id", sa.String(length=255), nullable=False),
sa.Column("start_time", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("end_time", sa.DateTime(timezone=True), nullable=True),
sa.Column("is_completed", sa.Boolean(), nullable=False),
sa.Column("scoring_mode_used", sa.String(length=50), nullable=False),
sa.Column("total_benar", sa.Integer(), nullable=False),
sa.Column("total_bobot_earned", sa.Float(), nullable=False),
sa.Column("NM", sa.Integer(), quote=True, nullable=True),
sa.Column("NN", sa.Integer(), quote=True, nullable=True),
sa.Column("theta", sa.Float(), nullable=True),
sa.Column("theta_se", sa.Float(), nullable=True),
sa.Column("rataan_used", sa.Float(), nullable=True),
sa.Column("sb_used", sa.Float(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["website_id"], ["websites.id"], ondelete="CASCADE", onupdate="CASCADE"),
sa.ForeignKeyConstraint(
["website_id", "tryout_id"],
["tryouts.website_id", "tryouts.tryout_id"],
name="fk_sessions_tryout",
ondelete="CASCADE",
onupdate="CASCADE",
),
sa.ForeignKeyConstraint(
["wp_user_id", "website_id"],
["users.wp_user_id", "users.website_id"],
name="fk_sessions_user",
ondelete="CASCADE",
onupdate="CASCADE",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("session_id"),
sa.CheckConstraint('"NM" IS NULL OR ("NM" >= 0 AND "NM" <= 1000)', name="ck_nm_range"),
sa.CheckConstraint('"NN" IS NULL OR ("NN" >= 0 AND "NN" <= 1000)', name="ck_nn_range"),
sa.CheckConstraint("theta IS NULL OR (theta >= -3 AND theta <= 3)", name="ck_theta_range"),
sa.CheckConstraint("total_benar >= 0", name="ck_total_benar_non_negative"),
sa.CheckConstraint("total_bobot_earned >= 0", name="ck_total_bobot_non_negative"),
)
op.create_index("ix_sessions_session_id", "sessions", ["session_id"], unique=True)
op.create_index("ix_sessions_wp_user_id", "sessions", ["wp_user_id"], unique=False)
op.create_index("ix_sessions_website_id", "sessions", ["website_id"], unique=False)
op.create_index("ix_sessions_tryout_id", "sessions", ["tryout_id"], unique=False)
op.create_index("ix_sessions_is_completed", "sessions", ["is_completed"], unique=False)
if not _table_exists("tryout_stats"):
op.create_table(
"tryout_stats",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("website_id", sa.Integer(), nullable=False),
sa.Column("tryout_id", sa.String(length=255), nullable=False),
sa.Column("participant_count", sa.Integer(), nullable=False),
sa.Column("total_nm_sum", sa.Float(), nullable=False),
sa.Column("total_nm_sq_sum", sa.Float(), nullable=False),
sa.Column("rataan", sa.Float(), nullable=True),
sa.Column("sb", sa.Float(), nullable=True),
sa.Column("min_nm", sa.Integer(), nullable=True),
sa.Column("max_nm", sa.Integer(), nullable=True),
sa.Column("last_calculated", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["website_id"], ["websites.id"], ondelete="CASCADE", onupdate="CASCADE"),
sa.ForeignKeyConstraint(
["website_id", "tryout_id"],
["tryouts.website_id", "tryouts.tryout_id"],
name="fk_tryout_stats_tryout",
ondelete="CASCADE",
onupdate="CASCADE",
),
sa.PrimaryKeyConstraint("id"),
sa.CheckConstraint("participant_count >= 0", name="ck_participant_count_non_negative"),
sa.CheckConstraint("min_nm IS NULL OR (min_nm >= 0 AND min_nm <= 1000)", name="ck_min_nm_range"),
sa.CheckConstraint("max_nm IS NULL OR (max_nm >= 0 AND max_nm <= 1000)", name="ck_max_nm_range"),
sa.CheckConstraint(
"min_nm IS NULL OR max_nm IS NULL OR min_nm <= max_nm",
name="ck_min_max_nm_order",
),
)
op.create_index("ix_tryout_stats_website_id", "tryout_stats", ["website_id"], unique=False)
op.create_index("ix_tryout_stats_tryout_id", "tryout_stats", ["tryout_id"], unique=False)
op.create_index(
"ix_tryout_stats_website_id_tryout_id",
"tryout_stats",
["website_id", "tryout_id"],
unique=True,
)
if not _table_exists("user_answers"):
op.create_table(
"user_answers",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("session_id", sa.String(length=255), nullable=False),
sa.Column("wp_user_id", sa.String(length=255), nullable=False),
sa.Column("website_id", sa.Integer(), nullable=False),
sa.Column("tryout_id", sa.String(length=255), nullable=False),
sa.Column("item_id", sa.Integer(), nullable=False),
sa.Column("response", sa.String(length=10), nullable=False),
sa.Column("is_correct", sa.Boolean(), nullable=False),
sa.Column("time_spent", sa.Integer(), nullable=False),
sa.Column("scoring_mode_used", sa.String(length=50), nullable=False),
sa.Column("bobot_earned", sa.Float(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["session_id"], ["sessions.session_id"], ondelete="CASCADE", onupdate="CASCADE"),
sa.ForeignKeyConstraint(["website_id"], ["websites.id"], ondelete="CASCADE", onupdate="CASCADE"),
sa.ForeignKeyConstraint(["item_id"], ["items.id"], ondelete="CASCADE", onupdate="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.CheckConstraint("time_spent >= 0", name="ck_time_spent_non_negative"),
sa.CheckConstraint("bobot_earned >= 0", name="ck_bobot_earned_non_negative"),
)
op.create_index("ix_user_answers_session_id", "user_answers", ["session_id"], unique=False)
op.create_index("ix_user_answers_wp_user_id", "user_answers", ["wp_user_id"], unique=False)
op.create_index("ix_user_answers_website_id", "user_answers", ["website_id"], unique=False)
op.create_index("ix_user_answers_tryout_id", "user_answers", ["tryout_id"], unique=False)
op.create_index("ix_user_answers_item_id", "user_answers", ["item_id"], unique=False)
op.create_index(
"ix_user_answers_session_id_item_id",
"user_answers",
["session_id", "item_id"],
unique=True,
)
def downgrade() -> None:
if _table_exists("user_answers"):
op.drop_table("user_answers")
if _table_exists("tryout_stats"):
op.drop_table("tryout_stats")
if _table_exists("sessions"):
op.drop_table("sessions")
if _table_exists("items"):
op.drop_table("items")
if _table_exists("users"):
op.drop_table("users")
if _table_exists("tryouts"):
op.drop_table("tryouts")
if _table_exists("websites"):
op.drop_table("websites")

View File

@@ -0,0 +1,118 @@
"""add tryout JSON snapshot tables
Revision ID: 20260402_000002
Revises: 20260331_000001
Create Date: 2026-04-02 11:30:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260402_000002"
down_revision: Union[str, None] = "20260331_000001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"tryout_import_snapshots",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("website_id", sa.Integer(), nullable=False),
sa.Column("source_tryout_id", sa.String(length=255), nullable=False),
sa.Column("source_key", sa.String(length=255), nullable=False),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("source_permalink", sa.String(length=1024), nullable=True),
sa.Column("source_status", sa.String(length=50), nullable=True),
sa.Column("exported_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("source_created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("source_modified_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("exported_by", sa.String(length=255), nullable=True),
sa.Column("question_count", sa.Integer(), nullable=False),
sa.Column("result_count", sa.Integer(), nullable=False),
sa.Column("payload_checksum", sa.String(length=64), nullable=False),
sa.Column("raw_payload", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["website_id"], ["websites.id"], ondelete="CASCADE", onupdate="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_tryout_import_snapshots_website_id",
"tryout_import_snapshots",
["website_id"],
unique=False,
)
op.create_index(
"ix_tryout_import_snapshots_source_tryout_id",
"tryout_import_snapshots",
["source_tryout_id"],
unique=False,
)
op.create_table(
"tryout_snapshot_questions",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("website_id", sa.Integer(), nullable=False),
sa.Column("source_tryout_id", sa.String(length=255), nullable=False),
sa.Column("source_question_id", sa.String(length=255), nullable=False),
sa.Column("latest_snapshot_id", sa.Integer(), nullable=True),
sa.Column("question_title", sa.Text(), nullable=False),
sa.Column("question_html", sa.Text(), nullable=False),
sa.Column("explanation_html", sa.Text(), nullable=True),
sa.Column("raw_options", sa.JSON(), nullable=False),
sa.Column("correct_answer", sa.String(length=10), nullable=False),
sa.Column("category_id", sa.Integer(), nullable=True),
sa.Column("category_name", sa.String(length=255), nullable=True),
sa.Column("category_code", sa.String(length=255), nullable=True),
sa.Column("option_count", sa.Integer(), nullable=False),
sa.Column("has_option_labels", sa.Boolean(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("content_checksum", sa.String(length=64), nullable=False),
sa.Column("raw_payload", sa.JSON(), nullable=False),
sa.Column("first_seen_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("last_seen_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["website_id"], ["websites.id"], ondelete="CASCADE", onupdate="CASCADE"),
sa.ForeignKeyConstraint(["latest_snapshot_id"], ["tryout_import_snapshots.id"], ondelete="SET NULL", onupdate="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"website_id",
"source_tryout_id",
"source_question_id",
name="uq_snapshot_questions_website_tryout_question",
),
)
op.create_index(
"ix_tryout_snapshot_questions_website_id",
"tryout_snapshot_questions",
["website_id"],
unique=False,
)
op.create_index(
"ix_tryout_snapshot_questions_source_tryout_id",
"tryout_snapshot_questions",
["source_tryout_id"],
unique=False,
)
op.create_index(
"ix_tryout_snapshot_questions_latest_snapshot_id",
"tryout_snapshot_questions",
["latest_snapshot_id"],
unique=False,
)
def downgrade() -> None:
op.drop_index("ix_tryout_snapshot_questions_latest_snapshot_id", table_name="tryout_snapshot_questions")
op.drop_index("ix_tryout_snapshot_questions_source_tryout_id", table_name="tryout_snapshot_questions")
op.drop_index("ix_tryout_snapshot_questions_website_id", table_name="tryout_snapshot_questions")
op.drop_table("tryout_snapshot_questions")
op.drop_index("ix_tryout_import_snapshots_source_tryout_id", table_name="tryout_import_snapshots")
op.drop_index("ix_tryout_import_snapshots_website_id", table_name="tryout_import_snapshots")
op.drop_table("tryout_import_snapshots")

View File

@@ -0,0 +1,118 @@
"""add ai generation runs and item variant lifecycle fields
Revision ID: 20260404_000003
Revises: 20260402_000002
Create Date: 2026-04-04 10:10:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260404_000003"
down_revision: Union[str, None] = "20260402_000002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"ai_generation_runs",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("basis_item_id", sa.Integer(), nullable=False),
sa.Column("source_snapshot_question_id", sa.Integer(), nullable=True),
sa.Column("target_level", sa.String(length=50), nullable=False),
sa.Column("requested_count", sa.Integer(), nullable=False, server_default="1"),
sa.Column("model", sa.String(length=255), nullable=False),
sa.Column("prompt_version", sa.String(length=50), nullable=False, server_default="v1"),
sa.Column("operator_notes", sa.Text(), nullable=True),
sa.Column("created_by", sa.String(length=255), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["basis_item_id"], ["items.id"], ondelete="CASCADE", onupdate="CASCADE"),
sa.ForeignKeyConstraint(
["source_snapshot_question_id"],
["tryout_snapshot_questions.id"],
ondelete="SET NULL",
onupdate="CASCADE",
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_ai_generation_runs_basis_item_id", "ai_generation_runs", ["basis_item_id"], unique=False)
op.create_index(
"ix_ai_generation_runs_source_snapshot_question_id",
"ai_generation_runs",
["source_snapshot_question_id"],
unique=False,
)
op.add_column("items", sa.Column("generation_run_id", sa.Integer(), nullable=True))
op.add_column("items", sa.Column("source_snapshot_question_id", sa.Integer(), nullable=True))
op.add_column("items", sa.Column("variant_status", sa.String(length=50), nullable=False, server_default="active"))
op.add_column("items", sa.Column("reviewed_by", sa.String(length=255), nullable=True))
op.add_column("items", sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("items", sa.Column("review_notes", sa.Text(), nullable=True))
op.create_foreign_key(
"fk_items_generation_run_id",
"items",
"ai_generation_runs",
["generation_run_id"],
["id"],
ondelete="SET NULL",
onupdate="CASCADE",
)
op.create_foreign_key(
"fk_items_source_snapshot_question_id",
"items",
"tryout_snapshot_questions",
["source_snapshot_question_id"],
["id"],
ondelete="SET NULL",
onupdate="CASCADE",
)
op.create_index("ix_items_generation_run_id", "items", ["generation_run_id"], unique=False)
op.create_index(
"ix_items_source_snapshot_question_id",
"items",
["source_snapshot_question_id"],
unique=False,
)
op.create_index("ix_items_variant_status", "items", ["variant_status"], unique=False)
op.drop_index("ix_items_tryout_id_website_id_slot", table_name="items")
op.create_index(
"ix_items_tryout_id_website_id_slot",
"items",
["tryout_id", "website_id", "slot", "level"],
unique=False,
)
op.alter_column("items", "variant_status", server_default=None)
def downgrade() -> None:
op.drop_index("ix_items_tryout_id_website_id_slot", table_name="items")
op.create_index(
"ix_items_tryout_id_website_id_slot",
"items",
["tryout_id", "website_id", "slot", "level"],
unique=True,
)
op.drop_index("ix_items_variant_status", table_name="items")
op.drop_index("ix_items_source_snapshot_question_id", table_name="items")
op.drop_index("ix_items_generation_run_id", table_name="items")
op.drop_constraint("fk_items_source_snapshot_question_id", "items", type_="foreignkey")
op.drop_constraint("fk_items_generation_run_id", "items", type_="foreignkey")
op.drop_column("items", "review_notes")
op.drop_column("items", "reviewed_at")
op.drop_column("items", "reviewed_by")
op.drop_column("items", "variant_status")
op.drop_column("items", "source_snapshot_question_id")
op.drop_column("items", "generation_run_id")
op.drop_index("ix_ai_generation_runs_source_snapshot_question_id", table_name="ai_generation_runs")
op.drop_index("ix_ai_generation_runs_basis_item_id", table_name="ai_generation_runs")
op.drop_table("ai_generation_runs")

View File

@@ -0,0 +1,53 @@
"""add persistent report schedules
Revision ID: 20260405_000004
Revises: 20260404_000003
Create Date: 2026-04-05 09:00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260405_000004"
down_revision: Union[str, None] = "20260404_000003"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"report_schedules",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("schedule_id", sa.String(length=36), nullable=False),
sa.Column("report_type", sa.String(length=50), nullable=False),
sa.Column("schedule", sa.String(length=20), nullable=False),
sa.Column("tryout_ids", sa.JSON(), nullable=False),
sa.Column("website_id", sa.Integer(), nullable=False),
sa.Column("recipients", sa.JSON(), nullable=False),
sa.Column("format", sa.String(length=10), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("last_run", sa.DateTime(timezone=True), nullable=True),
sa.Column("next_run", sa.DateTime(timezone=True), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(["website_id"], ["websites.id"], ondelete="CASCADE", onupdate="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("schedule_id"),
)
op.create_index("ix_report_schedules_schedule_id", "report_schedules", ["schedule_id"], unique=True)
op.create_index("ix_report_schedules_website_id", "report_schedules", ["website_id"], unique=False)
op.create_index(
"ix_report_schedules_website_active",
"report_schedules",
["website_id", "is_active"],
unique=False,
)
def downgrade() -> None:
op.drop_index("ix_report_schedules_website_active", table_name="report_schedules")
op.drop_index("ix_report_schedules_website_id", table_name="report_schedules")
op.drop_index("ix_report_schedules_schedule_id", table_name="report_schedules")
op.drop_table("report_schedules")

View File

@@ -0,0 +1,26 @@
"""add session expires at
Revision ID: 20260617_000005
Revises: 20260405_000004
Create Date: 2026-06-17 15:00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260617_000005"
down_revision: Union[str, None] = "20260405_000004"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("sessions", sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
op.drop_column("sessions", "expires_at")