Add comprehensive column check and migration 009

This commit is contained in:
Koncept Kit
2026-01-04 23:19:05 +07:00
parent d967d1934e
commit 340f838925
2 changed files with 329 additions and 0 deletions

View File

@@ -0,0 +1,237 @@
"""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')