forked from andika/membership-be
411 lines
20 KiB
Python
411 lines
20 KiB
Python
"""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
|