"""add_all_missing_columns Revision ID: 009_add_all_missing Revises: 008_add_donations Create Date: 2026-01-04 Fixes: - Add ALL remaining missing columns across all tables - Users: newsletter preferences, volunteer, scholarship, directory, password reset, ToS, member_since, reminders, rejection, import tracking - Events: calendar_uid - Subscriptions: base_subscription_cents, donation_cents, manual_payment - ImportJobs: WordPress import fields - Create ImportRollbackAudit table if not exists """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa from sqlalchemy.dialects.postgresql import UUID from sqlalchemy import inspect # revision identifiers, used by Alembic. revision: str = '009_add_all_missing' down_revision: Union[str, None] = '008_add_donations' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: """Add all missing columns across all tables""" conn = op.get_bind() inspector = inspect(conn) # ============================================================ # 1. USERS TABLE - Add ~28 missing columns # ============================================================ users_columns = {col['name'] for col in inspector.get_columns('users')} # Newsletter publication preferences if 'newsletter_publish_name' not in users_columns: op.add_column('users', sa.Column('newsletter_publish_name', sa.Boolean(), nullable=False, server_default='false')) if 'newsletter_publish_photo' not in users_columns: op.add_column('users', sa.Column('newsletter_publish_photo', sa.Boolean(), nullable=False, server_default='false')) if 'newsletter_publish_birthday' not in users_columns: op.add_column('users', sa.Column('newsletter_publish_birthday', sa.Boolean(), nullable=False, server_default='false')) if 'newsletter_publish_none' not in users_columns: op.add_column('users', sa.Column('newsletter_publish_none', sa.Boolean(), nullable=False, server_default='false')) # Volunteer interests if 'volunteer_interests' not in users_columns: op.add_column('users', sa.Column('volunteer_interests', sa.JSON(), nullable=True, server_default='[]')) # Scholarship if 'scholarship_requested' not in users_columns: op.add_column('users', sa.Column('scholarship_requested', sa.Boolean(), nullable=False, server_default='false')) # Directory if 'show_in_directory' not in users_columns: op.add_column('users', sa.Column('show_in_directory', sa.Boolean(), nullable=False, server_default='false')) # Password reset if 'password_reset_token' not in users_columns: op.add_column('users', sa.Column('password_reset_token', sa.String(), nullable=True)) if 'password_reset_expires' not in users_columns: op.add_column('users', sa.Column('password_reset_expires', sa.DateTime(), nullable=True)) if 'force_password_change' not in users_columns: op.add_column('users', sa.Column('force_password_change', sa.Boolean(), nullable=False, server_default='false')) # Terms of Service if 'accepts_tos' not in users_columns: op.add_column('users', sa.Column('accepts_tos', sa.Boolean(), nullable=False, server_default='false')) if 'tos_accepted_at' not in users_columns: op.add_column('users', sa.Column('tos_accepted_at', sa.DateTime(), nullable=True)) # Member since if 'member_since' not in users_columns: op.add_column('users', sa.Column('member_since', sa.DateTime(), nullable=True)) # Email verification reminders if 'email_verification_reminders_sent' not in users_columns: op.add_column('users', sa.Column('email_verification_reminders_sent', sa.Integer(), nullable=False, server_default='0')) if 'last_email_verification_reminder_at' not in users_columns: op.add_column('users', sa.Column('last_email_verification_reminder_at', sa.DateTime(), nullable=True)) # Event attendance reminders if 'event_attendance_reminders_sent' not in users_columns: op.add_column('users', sa.Column('event_attendance_reminders_sent', sa.Integer(), nullable=False, server_default='0')) if 'last_event_attendance_reminder_at' not in users_columns: op.add_column('users', sa.Column('last_event_attendance_reminder_at', sa.DateTime(), nullable=True)) # Payment reminders if 'payment_reminders_sent' not in users_columns: op.add_column('users', sa.Column('payment_reminders_sent', sa.Integer(), nullable=False, server_default='0')) if 'last_payment_reminder_at' not in users_columns: op.add_column('users', sa.Column('last_payment_reminder_at', sa.DateTime(), nullable=True)) # Renewal reminders if 'renewal_reminders_sent' not in users_columns: op.add_column('users', sa.Column('renewal_reminders_sent', sa.Integer(), nullable=False, server_default='0')) if 'last_renewal_reminder_at' not in users_columns: op.add_column('users', sa.Column('last_renewal_reminder_at', sa.DateTime(), nullable=True)) # Rejection tracking if 'rejection_reason' not in users_columns: op.add_column('users', sa.Column('rejection_reason', sa.Text(), nullable=True)) if 'rejected_at' not in users_columns: op.add_column('users', sa.Column('rejected_at', sa.DateTime(timezone=True), nullable=True)) if 'rejected_by' not in users_columns: op.add_column('users', sa.Column('rejected_by', UUID(as_uuid=True), nullable=True)) # Note: Foreign key constraint skipped to avoid circular dependency issues # WordPress import tracking if 'import_source' not in users_columns: op.add_column('users', sa.Column('import_source', sa.String(50), nullable=True)) if 'import_job_id' not in users_columns: op.add_column('users', sa.Column('import_job_id', UUID(as_uuid=True), nullable=True)) # Note: Foreign key will be added after import_jobs table is updated if 'wordpress_user_id' not in users_columns: op.add_column('users', sa.Column('wordpress_user_id', sa.BigInteger(), nullable=True)) if 'wordpress_registered_date' not in users_columns: op.add_column('users', sa.Column('wordpress_registered_date', sa.DateTime(timezone=True), nullable=True)) # ============================================================ # 2. EVENTS TABLE - Add calendar_uid # ============================================================ events_columns = {col['name'] for col in inspector.get_columns('events')} if 'calendar_uid' not in events_columns: op.add_column('events', sa.Column('calendar_uid', sa.String(), nullable=True)) # ============================================================ # 3. SUBSCRIPTIONS TABLE - Add donation tracking # ============================================================ subscriptions_columns = {col['name'] for col in inspector.get_columns('subscriptions')} if 'base_subscription_cents' not in subscriptions_columns: op.add_column('subscriptions', sa.Column('base_subscription_cents', sa.Integer(), nullable=True)) # Update existing rows: base_subscription_cents = amount_paid_cents - donation_cents (default 0) op.execute("UPDATE subscriptions SET base_subscription_cents = COALESCE(amount_paid_cents, 0) WHERE base_subscription_cents IS NULL") # Make it non-nullable after populating op.alter_column('subscriptions', 'base_subscription_cents', nullable=False) if 'donation_cents' not in subscriptions_columns: op.add_column('subscriptions', sa.Column('donation_cents', sa.Integer(), nullable=False, server_default='0')) if 'manual_payment' not in subscriptions_columns: op.add_column('subscriptions', sa.Column('manual_payment', sa.Boolean(), nullable=False, server_default='false')) # ============================================================ # 4. IMPORT_JOBS TABLE - Add WordPress import fields # ============================================================ import_jobs_columns = {col['name'] for col in inspector.get_columns('import_jobs')} if 'field_mapping' not in import_jobs_columns: op.add_column('import_jobs', sa.Column('field_mapping', sa.JSON(), nullable=False, server_default='{}')) if 'wordpress_metadata' not in import_jobs_columns: op.add_column('import_jobs', sa.Column('wordpress_metadata', sa.JSON(), nullable=False, server_default='{}')) if 'imported_user_ids' not in import_jobs_columns: op.add_column('import_jobs', sa.Column('imported_user_ids', sa.JSON(), nullable=False, server_default='[]')) if 'rollback_at' not in import_jobs_columns: op.add_column('import_jobs', sa.Column('rollback_at', sa.DateTime(), nullable=True)) if 'rollback_by' not in import_jobs_columns: op.add_column('import_jobs', sa.Column('rollback_by', UUID(as_uuid=True), nullable=True)) # Foreign key will be added if needed # ============================================================ # 5. CREATE IMPORT_ROLLBACK_AUDIT TABLE # ============================================================ if 'import_rollback_audit' not in inspector.get_table_names(): op.create_table( 'import_rollback_audit', sa.Column('id', UUID(as_uuid=True), primary_key=True), sa.Column('import_job_id', UUID(as_uuid=True), sa.ForeignKey('import_jobs.id'), nullable=False), sa.Column('rolled_back_by', UUID(as_uuid=True), sa.ForeignKey('users.id'), nullable=False), sa.Column('rolled_back_at', sa.DateTime(), nullable=False), sa.Column('deleted_user_count', sa.Integer(), nullable=False), sa.Column('deleted_user_ids', sa.JSON(), nullable=False), sa.Column('reason', sa.Text(), nullable=True), sa.Column('created_at', sa.DateTime(), nullable=False) ) def downgrade() -> None: """Remove all added columns and tables""" # Drop import_rollback_audit table op.drop_table('import_rollback_audit') # Drop import_jobs columns op.drop_column('import_jobs', 'rollback_by') op.drop_column('import_jobs', 'rollback_at') op.drop_column('import_jobs', 'imported_user_ids') op.drop_column('import_jobs', 'wordpress_metadata') op.drop_column('import_jobs', 'field_mapping') # Drop subscriptions columns op.drop_column('subscriptions', 'manual_payment') op.drop_column('subscriptions', 'donation_cents') op.drop_column('subscriptions', 'base_subscription_cents') # Drop events columns op.drop_column('events', 'calendar_uid') # Drop users columns (in reverse order) op.drop_column('users', 'wordpress_registered_date') op.drop_column('users', 'wordpress_user_id') op.drop_column('users', 'import_job_id') op.drop_column('users', 'import_source') op.drop_column('users', 'rejected_by') op.drop_column('users', 'rejected_at') op.drop_column('users', 'rejection_reason') op.drop_column('users', 'last_renewal_reminder_at') op.drop_column('users', 'renewal_reminders_sent') op.drop_column('users', 'last_payment_reminder_at') op.drop_column('users', 'payment_reminders_sent') op.drop_column('users', 'last_event_attendance_reminder_at') op.drop_column('users', 'event_attendance_reminders_sent') op.drop_column('users', 'last_email_verification_reminder_at') op.drop_column('users', 'email_verification_reminders_sent') op.drop_column('users', 'member_since') op.drop_column('users', 'tos_accepted_at') op.drop_column('users', 'accepts_tos') op.drop_column('users', 'force_password_change') op.drop_column('users', 'password_reset_expires') op.drop_column('users', 'password_reset_token') op.drop_column('users', 'show_in_directory') op.drop_column('users', 'scholarship_requested') op.drop_column('users', 'volunteer_interests') op.drop_column('users', 'newsletter_publish_none') op.drop_column('users', 'newsletter_publish_birthday') op.drop_column('users', 'newsletter_publish_photo') op.drop_column('users', 'newsletter_publish_name')