"""align_prod_with_dev Revision ID: 011_align_prod_dev Revises: 010_add_email_exp Create Date: 2026-01-05 Aligns PROD database schema with DEV database schema (source of truth). Fixes type mismatches, removes PROD-only columns, adds DEV-only columns, updates nullable constraints. """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa from sqlalchemy.dialects.postgresql import JSONB, JSON # revision identifiers, used by Alembic. revision: str = '011_align_prod_dev' down_revision: Union[str, None] = '010_add_email_exp' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: """Align PROD schema with DEV schema (source of truth)""" from sqlalchemy import inspect conn = op.get_bind() inspector = inspect(conn) print("Starting schema alignment: PROD → DEV (source of truth)...") # ============================================================ # 1. FIX USERS TABLE # ============================================================ print("\n[1/14] Fixing users table...") users_columns = {col['name'] for col in inspector.get_columns('users')} # Remove PROD-only columns (not in models.py or DEV) if 'bio' in users_columns: op.drop_column('users', 'bio') print(" ✓ Removed users.bio (PROD-only)") if 'interests' in users_columns: op.drop_column('users', 'interests') print(" ✓ Removed users.interests (PROD-only)") try: # Change constrained VARCHAR(n) to unconstrained VARCHAR op.alter_column('users', 'first_name', type_=sa.String(), postgresql_using='first_name::varchar') op.alter_column('users', 'last_name', type_=sa.String(), postgresql_using='last_name::varchar') op.alter_column('users', 'email', type_=sa.String(), postgresql_using='email::varchar') op.alter_column('users', 'phone', type_=sa.String(), postgresql_using='phone::varchar') op.alter_column('users', 'city', type_=sa.String(), postgresql_using='city::varchar') op.alter_column('users', 'state', type_=sa.String(), postgresql_using='state::varchar') op.alter_column('users', 'zipcode', type_=sa.String(), postgresql_using='zipcode::varchar') op.alter_column('users', 'partner_first_name', type_=sa.String(), postgresql_using='partner_first_name::varchar') op.alter_column('users', 'partner_last_name', type_=sa.String(), postgresql_using='partner_last_name::varchar') op.alter_column('users', 'referred_by_member_name', type_=sa.String(), postgresql_using='referred_by_member_name::varchar') op.alter_column('users', 'password_hash', type_=sa.String(), postgresql_using='password_hash::varchar') op.alter_column('users', 'email_verification_token', type_=sa.String(), postgresql_using='email_verification_token::varchar') op.alter_column('users', 'password_reset_token', type_=sa.String(), postgresql_using='password_reset_token::varchar') print(" ✓ Changed VARCHAR(n) to VARCHAR") # Change TEXT to VARCHAR op.alter_column('users', 'address', type_=sa.String(), postgresql_using='address::varchar') op.alter_column('users', 'profile_photo_url', type_=sa.String(), postgresql_using='profile_photo_url::varchar') print(" ✓ Changed TEXT to VARCHAR") # Change DATE to TIMESTAMP op.alter_column('users', 'date_of_birth', type_=sa.DateTime(), postgresql_using='date_of_birth::timestamp') op.alter_column('users', 'member_since', type_=sa.DateTime(), postgresql_using='member_since::timestamp') print(" ✓ Changed DATE to TIMESTAMP") # Change JSONB to JSON op.alter_column('users', 'lead_sources', type_=JSON(), postgresql_using='lead_sources::json') print(" ✓ Changed lead_sources JSONB to JSON") # Change TEXT to JSON for volunteer_interests op.alter_column('users', 'volunteer_interests', type_=JSON(), postgresql_using='volunteer_interests::json') print(" ✓ Changed volunteer_interests TEXT to JSON") except Exception as e: print(f" ⚠️ Warning: Some type conversions failed: {e}") # Fill NULL values with defaults BEFORE setting NOT NULL constraints print(" ⏳ Filling NULL values with defaults...") # Update string fields conn.execute(sa.text("UPDATE users SET address = '' WHERE address IS NULL")) conn.execute(sa.text("UPDATE users SET city = '' WHERE city IS NULL")) conn.execute(sa.text("UPDATE users SET state = '' WHERE state IS NULL")) conn.execute(sa.text("UPDATE users SET zipcode = '' WHERE zipcode IS NULL")) conn.execute(sa.text("UPDATE users SET phone = '' WHERE phone IS NULL")) # Update date_of_birth with sentinel date conn.execute(sa.text("UPDATE users SET date_of_birth = '1900-01-01'::timestamp WHERE date_of_birth IS NULL")) # Update boolean fields conn.execute(sa.text("UPDATE users SET show_in_directory = false WHERE show_in_directory IS NULL")) conn.execute(sa.text("UPDATE users SET newsletter_publish_name = false WHERE newsletter_publish_name IS NULL")) conn.execute(sa.text("UPDATE users SET newsletter_publish_birthday = false WHERE newsletter_publish_birthday IS NULL")) conn.execute(sa.text("UPDATE users SET newsletter_publish_photo = false WHERE newsletter_publish_photo IS NULL")) conn.execute(sa.text("UPDATE users SET newsletter_publish_none = false WHERE newsletter_publish_none IS NULL")) conn.execute(sa.text("UPDATE users SET force_password_change = false WHERE force_password_change IS NULL")) conn.execute(sa.text("UPDATE users SET scholarship_requested = false WHERE scholarship_requested IS NULL")) conn.execute(sa.text("UPDATE users SET accepts_tos = false WHERE accepts_tos IS NULL")) # Check how many rows were updated null_check = conn.execute(sa.text(""" SELECT COUNT(*) FILTER (WHERE address = '') as address_filled, COUNT(*) FILTER (WHERE date_of_birth = '1900-01-01'::timestamp) as dob_filled FROM users """)).fetchone() print(f" ✓ Filled NULLs: {null_check[0]} addresses, {null_check[1]} dates of birth") # Now safe to set NOT NULL constraints op.alter_column('users', 'address', nullable=False) op.alter_column('users', 'city', nullable=False) op.alter_column('users', 'state', nullable=False) op.alter_column('users', 'zipcode', nullable=False) op.alter_column('users', 'phone', nullable=False) op.alter_column('users', 'date_of_birth', nullable=False) op.alter_column('users', 'show_in_directory', nullable=False) op.alter_column('users', 'newsletter_publish_name', nullable=False) op.alter_column('users', 'newsletter_publish_birthday', nullable=False) op.alter_column('users', 'newsletter_publish_photo', nullable=False) op.alter_column('users', 'newsletter_publish_none', nullable=False) op.alter_column('users', 'force_password_change', nullable=False) op.alter_column('users', 'scholarship_requested', nullable=False) op.alter_column('users', 'accepts_tos', nullable=False) print(" ✓ Set NOT NULL constraints") # ============================================================ # 2. FIX DONATIONS TABLE # ============================================================ print("\n[2/14] Fixing donations table...") donations_columns = {col['name'] for col in inspector.get_columns('donations')} # Remove PROD-only columns if 'is_anonymous' in donations_columns: op.drop_column('donations', 'is_anonymous') print(" ✓ Removed donations.is_anonymous (PROD-only)") if 'completed_at' in donations_columns: op.drop_column('donations', 'completed_at') print(" ✓ Removed donations.completed_at (PROD-only)") if 'message' in donations_columns: op.drop_column('donations', 'message') print(" ✓ Removed donations.message (PROD-only)") try: op.alter_column('donations', 'donor_email', type_=sa.String(), postgresql_using='donor_email::varchar') op.alter_column('donations', 'donor_name', type_=sa.String(), postgresql_using='donor_name::varchar') op.alter_column('donations', 'stripe_payment_intent_id', type_=sa.String(), postgresql_using='stripe_payment_intent_id::varchar') print(" ✓ Changed VARCHAR(n) to VARCHAR") except Exception as e: print(f" ⚠️ Warning: Type conversion failed: {e}") # ============================================================ # 3. FIX SUBSCRIPTIONS TABLE # ============================================================ print("\n[3/14] Fixing subscriptions table...") subscriptions_columns = {col['name'] for col in inspector.get_columns('subscriptions')} # Remove PROD-only columns if 'cancel_at_period_end' in subscriptions_columns: op.drop_column('subscriptions', 'cancel_at_period_end') print(" ✓ Removed subscriptions.cancel_at_period_end (PROD-only)") if 'canceled_at' in subscriptions_columns: op.drop_column('subscriptions', 'canceled_at') print(" ✓ Removed subscriptions.canceled_at (PROD-only)") if 'current_period_start' in subscriptions_columns: op.drop_column('subscriptions', 'current_period_start') print(" ✓ Removed subscriptions.current_period_start (PROD-only)") if 'current_period_end' in subscriptions_columns: op.drop_column('subscriptions', 'current_period_end') print(" ✓ Removed subscriptions.current_period_end (PROD-only)") try: op.alter_column('subscriptions', 'stripe_subscription_id', type_=sa.String(), postgresql_using='stripe_subscription_id::varchar') op.alter_column('subscriptions', 'stripe_customer_id', type_=sa.String(), postgresql_using='stripe_customer_id::varchar') op.alter_column('subscriptions', 'payment_method', type_=sa.String(), postgresql_using='payment_method::varchar') print(" ✓ Changed VARCHAR(n) to VARCHAR") except Exception as e: print(f" ⚠️ Warning: Type conversion failed: {e}") # Fix nullable constraints op.alter_column('subscriptions', 'start_date', nullable=False) op.alter_column('subscriptions', 'manual_payment', nullable=False) op.alter_column('subscriptions', 'donation_cents', nullable=False) op.alter_column('subscriptions', 'base_subscription_cents', nullable=False) print(" ✓ Fixed nullable constraints") # ============================================================ # 4. FIX STORAGE_USAGE TABLE # ============================================================ print("\n[4/14] Fixing storage_usage table...") storage_columns = {col['name'] for col in inspector.get_columns('storage_usage')} # Remove PROD-only columns if 'created_at' in storage_columns: op.drop_column('storage_usage', 'created_at') print(" ✓ Removed storage_usage.created_at (PROD-only)") if 'updated_at' in storage_columns: op.drop_column('storage_usage', 'updated_at') print(" ✓ Removed storage_usage.updated_at (PROD-only)") op.alter_column('storage_usage', 'max_bytes_allowed', nullable=False) print(" ✓ Fixed nullable constraint") # ============================================================ # 5. FIX EVENT_GALLERIES TABLE (Add missing DEV columns) # ============================================================ print("\n[5/14] Fixing event_galleries table...") event_galleries_columns = {col['name'] for col in inspector.get_columns('event_galleries')} # Add DEV-only columns (exist in models.py but not in PROD) if 'image_key' not in event_galleries_columns: op.add_column('event_galleries', sa.Column('image_key', sa.String(), nullable=False, server_default='')) print(" ✓ Added event_galleries.image_key") if 'file_size_bytes' not in event_galleries_columns: op.add_column('event_galleries', sa.Column('file_size_bytes', sa.Integer(), nullable=False, server_default='0')) print(" ✓ Added event_galleries.file_size_bytes") try: op.alter_column('event_galleries', 'image_url', type_=sa.String(), postgresql_using='image_url::varchar') print(" ✓ Changed TEXT to VARCHAR") except Exception as e: print(f" ⚠️ Warning: Type conversion failed: {e}") # Note: uploaded_by column already has correct nullable=False in both DEV and PROD # ============================================================ # 6. FIX BYLAWS_DOCUMENTS TABLE # ============================================================ print("\n[6/14] Fixing bylaws_documents table...") bylaws_columns = {col['name'] for col in inspector.get_columns('bylaws_documents')} # Remove PROD-only column if 'updated_at' in bylaws_columns: op.drop_column('bylaws_documents', 'updated_at') print(" ✓ Removed bylaws_documents.updated_at (PROD-only)") try: op.alter_column('bylaws_documents', 'title', type_=sa.String(), postgresql_using='title::varchar') op.alter_column('bylaws_documents', 'version', type_=sa.String(), postgresql_using='version::varchar') op.alter_column('bylaws_documents', 'document_url', type_=sa.String(), postgresql_using='document_url::varchar') op.alter_column('bylaws_documents', 'document_type', type_=sa.String(), postgresql_using='document_type::varchar') print(" ✓ Changed column types") except Exception as e: print(f" ⚠️ Warning: Type conversion failed: {e}") op.alter_column('bylaws_documents', 'document_type', nullable=True) print(" ✓ Fixed nullable constraint") # ============================================================ # 7. FIX EVENTS TABLE # ============================================================ print("\n[7/14] Fixing events table...") try: op.alter_column('events', 'title', type_=sa.String(), postgresql_using='title::varchar') op.alter_column('events', 'location', type_=sa.String(), postgresql_using='location::varchar') op.alter_column('events', 'calendar_uid', type_=sa.String(), postgresql_using='calendar_uid::varchar') print(" ✓ Changed VARCHAR(n) to VARCHAR") except Exception as e: print(f" ⚠️ Warning: {e}") op.alter_column('events', 'location', nullable=False) op.alter_column('events', 'created_by', nullable=False) print(" ✓ Fixed nullable constraints") # ============================================================ # 8. FIX PERMISSIONS TABLE # ============================================================ print("\n[8/14] Fixing permissions table...") try: op.alter_column('permissions', 'code', type_=sa.String(), postgresql_using='code::varchar') op.alter_column('permissions', 'name', type_=sa.String(), postgresql_using='name::varchar') op.alter_column('permissions', 'module', type_=sa.String(), postgresql_using='module::varchar') print(" ✓ Changed VARCHAR(n) to VARCHAR") except Exception as e: print(f" ⚠️ Warning: {e}") op.alter_column('permissions', 'module', nullable=False) print(" ✓ Fixed nullable constraint") # ============================================================ # 9. FIX ROLES TABLE # ============================================================ print("\n[9/14] Fixing roles table...") try: op.alter_column('roles', 'code', type_=sa.String(), postgresql_using='code::varchar') op.alter_column('roles', 'name', type_=sa.String(), postgresql_using='name::varchar') print(" ✓ Changed VARCHAR(n) to VARCHAR") except Exception as e: print(f" ⚠️ Warning: {e}") op.alter_column('roles', 'is_system_role', nullable=False) print(" ✓ Fixed nullable constraint") # ============================================================ # 10. FIX USER_INVITATIONS TABLE # ============================================================ print("\n[10/14] Fixing user_invitations table...") try: op.alter_column('user_invitations', 'email', type_=sa.String(), postgresql_using='email::varchar') op.alter_column('user_invitations', 'token', type_=sa.String(), postgresql_using='token::varchar') print(" ✓ Changed VARCHAR(n) to VARCHAR") except Exception as e: print(f" ⚠️ Warning: {e}") op.alter_column('user_invitations', 'invited_at', nullable=False) print(" ✓ Fixed nullable constraint") # ============================================================ # 11. FIX NEWSLETTER_ARCHIVES TABLE # ============================================================ print("\n[11/14] Fixing newsletter_archives table...") try: op.alter_column('newsletter_archives', 'title', type_=sa.String(), postgresql_using='title::varchar') op.alter_column('newsletter_archives', 'document_url', type_=sa.String(), postgresql_using='document_url::varchar') op.alter_column('newsletter_archives', 'document_type', type_=sa.String(), postgresql_using='document_type::varchar') print(" ✓ Changed column types") except Exception as e: print(f" ⚠️ Warning: {e}") op.alter_column('newsletter_archives', 'document_type', nullable=True) print(" ✓ Fixed nullable constraint") # ============================================================ # 12. FIX FINANCIAL_REPORTS TABLE # ============================================================ print("\n[12/14] Fixing financial_reports table...") try: op.alter_column('financial_reports', 'title', type_=sa.String(), postgresql_using='title::varchar') op.alter_column('financial_reports', 'document_url', type_=sa.String(), postgresql_using='document_url::varchar') op.alter_column('financial_reports', 'document_type', type_=sa.String(), postgresql_using='document_type::varchar') print(" ✓ Changed column types") except Exception as e: print(f" ⚠️ Warning: {e}") op.alter_column('financial_reports', 'document_type', nullable=True) print(" ✓ Fixed nullable constraint") # ============================================================ # 13. FIX IMPORT_JOBS TABLE # ============================================================ print("\n[13/14] Fixing import_jobs table...") try: op.alter_column('import_jobs', 'filename', type_=sa.String(), postgresql_using='filename::varchar') op.alter_column('import_jobs', 'file_key', type_=sa.String(), postgresql_using='file_key::varchar') print(" ✓ Changed VARCHAR(n) to VARCHAR") # Change JSONB to JSON op.alter_column('import_jobs', 'errors', type_=JSON(), postgresql_using='errors::json') print(" ✓ Changed errors JSONB to JSON") except Exception as e: print(f" ⚠️ Warning: {e}") # Fix nullable constraints op.alter_column('import_jobs', 'processed_rows', nullable=False) op.alter_column('import_jobs', 'successful_rows', nullable=False) op.alter_column('import_jobs', 'failed_rows', nullable=False) op.alter_column('import_jobs', 'errors', nullable=False) op.alter_column('import_jobs', 'started_at', nullable=False) print(" ✓ Fixed nullable constraints") # ============================================================ # 14. FIX SUBSCRIPTION_PLANS TABLE # ============================================================ print("\n[14/14] Fixing subscription_plans table...") try: op.alter_column('subscription_plans', 'name', type_=sa.String(), postgresql_using='name::varchar') op.alter_column('subscription_plans', 'billing_cycle', type_=sa.String(), postgresql_using='billing_cycle::varchar') op.alter_column('subscription_plans', 'stripe_price_id', type_=sa.String(), postgresql_using='stripe_price_id::varchar') print(" ✓ Changed VARCHAR(n) to VARCHAR") except Exception as e: print(f" ⚠️ Warning: {e}") op.alter_column('subscription_plans', 'minimum_price_cents', nullable=False) print(" ✓ Fixed nullable constraint") print("\n✅ Schema alignment complete! PROD now matches DEV (source of truth)") def downgrade() -> None: """Revert alignment changes (not recommended)""" print("⚠️ Downgrade not supported for alignment migration") print(" To revert, restore from backup") pass