import logging from dataclasses import dataclass from typing import Callable, List, Set from sqlalchemy import inspect, text from sqlalchemy.engine import Connection @dataclass(frozen=True) class Migration: id: str description: str upgrade: Callable[[Connection], None] def _ensure_migrations_table(connection: Connection) -> None: connection.execute( text( """ CREATE TABLE IF NOT EXISTS schema_migrations ( id VARCHAR(255) PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) """ ) ) def _migration_0001_add_channel_subscription_fields(connection: Connection) -> None: inspector = inspect(connection) columns: Set[str] = {col["name"] for col in inspector.get_columns("users")} statements: List[str] = [] if "channel_subscription_verified" not in columns: statements.append("ALTER TABLE users ADD COLUMN channel_subscription_verified BOOLEAN") if "channel_subscription_checked_at" not in columns: statements.append( "ALTER TABLE users ADD COLUMN channel_subscription_checked_at TIMESTAMPTZ" ) if "channel_subscription_verified_for" not in columns: statements.append("ALTER TABLE users ADD COLUMN channel_subscription_verified_for BIGINT") for stmt in statements: connection.execute(text(stmt)) def _migration_0002_add_referral_code(connection: Connection) -> None: inspector = inspect(connection) columns: Set[str] = {col["name"] for col in inspector.get_columns("users")} if "referral_code" not in columns: connection.execute(text("ALTER TABLE users ADD COLUMN referral_code VARCHAR(16)")) connection.execute( text( """ WITH generated_codes AS ( SELECT user_id, UPPER( SUBSTRING( md5( user_id::text || clock_timestamp()::text || random()::text ) FROM 1 FOR 9 ) ) AS referral_code FROM users WHERE referral_code IS NULL OR referral_code = '' ) UPDATE users AS u SET referral_code = g.referral_code FROM generated_codes AS g WHERE u.user_id = g.user_id """ ) ) connection.execute( text( """ CREATE UNIQUE INDEX IF NOT EXISTS uq_users_referral_code ON users (referral_code) WHERE referral_code IS NOT NULL """ ) ) def _migration_0003_normalize_referral_codes(connection: Connection) -> None: inspector = inspect(connection) columns: Set[str] = {col["name"] for col in inspector.get_columns("users")} if "referral_code" not in columns: return connection.execute( text( """ UPDATE users SET referral_code = UPPER(referral_code) WHERE referral_code IS NOT NULL AND referral_code <> UPPER(referral_code) """ ) ) def _migration_0004_add_lifetime_used_traffic(connection: Connection) -> None: inspector = inspect(connection) columns: Set[str] = {col["name"] for col in inspector.get_columns("users")} if "lifetime_used_traffic_bytes" in columns: return connection.execute(text("ALTER TABLE users ADD COLUMN lifetime_used_traffic_bytes BIGINT")) def _migration_0005_add_email_auth_fields(connection: Connection) -> None: inspector = inspect(connection) columns: Set[str] = {col["name"] for col in inspector.get_columns("users")} if "email" not in columns: connection.execute(text("ALTER TABLE users ADD COLUMN email VARCHAR")) if "email_verified_at" not in columns: connection.execute(text("ALTER TABLE users ADD COLUMN email_verified_at TIMESTAMPTZ")) if "telegram_id" not in columns: connection.execute(text("ALTER TABLE users ADD COLUMN telegram_id BIGINT")) connection.execute( text( """ UPDATE users SET telegram_id = user_id WHERE telegram_id IS NULL AND user_id > 0 """ ) ) connection.execute( text( """ CREATE UNIQUE INDEX IF NOT EXISTS uq_users_email ON users (email) WHERE email IS NOT NULL """ ) ) connection.execute( text( """ CREATE UNIQUE INDEX IF NOT EXISTS uq_users_telegram_id ON users (telegram_id) WHERE telegram_id IS NOT NULL """ ) ) connection.execute( text( """ CREATE TABLE IF NOT EXISTS email_verification_codes ( code_id SERIAL PRIMARY KEY, email VARCHAR NOT NULL, code_hash VARCHAR NOT NULL, purpose VARCHAR NOT NULL, target_user_id BIGINT NULL REFERENCES users(user_id), expires_at TIMESTAMPTZ NOT NULL, consumed_at TIMESTAMPTZ NULL, attempts INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) """ ) ) connection.execute( text( """ CREATE INDEX IF NOT EXISTS ix_email_verification_codes_lookup ON email_verification_codes (email, purpose, target_user_id, created_at DESC) """ ) ) connection.execute( text( """ CREATE INDEX IF NOT EXISTS ix_email_verification_codes_expires_at ON email_verification_codes (expires_at) """ ) ) def _migration_0006_add_security_throttles(connection: Connection) -> None: connection.execute( text( """ CREATE TABLE IF NOT EXISTS security_throttles ( throttle_id SERIAL PRIMARY KEY, scope VARCHAR(64) NOT NULL, identifier VARCHAR(512) NOT NULL, failures INTEGER NOT NULL DEFAULT 0, window_started_at TIMESTAMPTZ NULL, locked_until TIMESTAMPTZ NULL, last_attempt_at TIMESTAMPTZ NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NULL, CONSTRAINT uq_security_throttles_scope_identifier UNIQUE (scope, identifier) ) """ ) ) connection.execute( text( """ CREATE INDEX IF NOT EXISTS ix_security_throttles_scope ON security_throttles (scope) """ ) ) connection.execute( text( """ CREATE INDEX IF NOT EXISTS ix_security_throttles_locked_until ON security_throttles (locked_until) """ ) ) def _migration_0007_add_telegram_photo_url(connection: Connection) -> None: inspector = inspect(connection) columns: Set[str] = {col["name"] for col in inspector.get_columns("users")} if "telegram_photo_url" in columns: return connection.execute(text("ALTER TABLE users ADD COLUMN telegram_photo_url TEXT")) def _migration_0008_add_email_verification_code_status(connection: Connection) -> None: inspector = inspect(connection) columns: Set[str] = {col["name"] for col in inspector.get_columns("email_verification_codes")} if "status" not in columns: connection.execute( text( "ALTER TABLE email_verification_codes ADD COLUMN status VARCHAR NOT NULL DEFAULT 'active'" # noqa: E501 ) ) else: connection.execute( text( """ UPDATE email_verification_codes SET status = 'active' WHERE status IS NULL OR status = '' """ ) ) connection.execute( text( """ CREATE INDEX IF NOT EXISTS ix_email_verification_codes_status ON email_verification_codes (status) """ ) ) def _migration_0010_add_email_magic_token_hash(connection: Connection) -> None: inspector = inspect(connection) columns: Set[str] = {col["name"] for col in inspector.get_columns("email_verification_codes")} if "magic_token_hash" not in columns: connection.execute( text("ALTER TABLE email_verification_codes ADD COLUMN magic_token_hash VARCHAR") ) connection.execute( text( """ CREATE INDEX IF NOT EXISTS ix_email_verification_codes_magic_token_hash ON email_verification_codes (magic_token_hash) """ ) ) def _migration_0011_add_user_telegram_avatars(connection: Connection) -> None: connection.execute( text( """ CREATE TABLE IF NOT EXISTS user_telegram_avatars ( user_id BIGINT PRIMARY KEY REFERENCES users(user_id), file_unique_id VARCHAR, content_type VARCHAR(64) NOT NULL DEFAULT 'image/jpeg', image_bytes BYTEA NOT NULL, size_bytes INTEGER NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) """ ) ) connection.execute( text( """ CREATE INDEX IF NOT EXISTS ix_user_telegram_avatars_file_unique_id ON user_telegram_avatars (file_unique_id) """ ) ) def _migration_0012_add_tariffs_schema(connection: Connection) -> None: inspector = inspect(connection) sub_columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")} sub_statements: List[str] = [] if "tariff_key" not in sub_columns: sub_statements.append("ALTER TABLE subscriptions ADD COLUMN tariff_key VARCHAR") if "tier_baseline_bytes" not in sub_columns: sub_statements.append("ALTER TABLE subscriptions ADD COLUMN tier_baseline_bytes BIGINT") if "topup_balance_bytes" not in sub_columns: sub_statements.append( "ALTER TABLE subscriptions ADD COLUMN topup_balance_bytes BIGINT NOT NULL DEFAULT 0" ) if "premium_baseline_bytes" not in sub_columns: sub_statements.append( "ALTER TABLE subscriptions ADD COLUMN premium_baseline_bytes BIGINT NOT NULL DEFAULT 0" ) if "premium_topup_balance_bytes" not in sub_columns: sub_statements.append( "ALTER TABLE subscriptions ADD COLUMN premium_topup_balance_bytes BIGINT NOT NULL DEFAULT 0" # noqa: E501 ) if "premium_topup_used_bytes" not in sub_columns: sub_statements.append( "ALTER TABLE subscriptions ADD COLUMN premium_topup_used_bytes BIGINT NOT NULL DEFAULT 0" # noqa: E501 ) if "premium_used_bytes" not in sub_columns: sub_statements.append( "ALTER TABLE subscriptions ADD COLUMN premium_used_bytes BIGINT NOT NULL DEFAULT 0" ) if "premium_is_limited" not in sub_columns: sub_statements.append( "ALTER TABLE subscriptions ADD COLUMN premium_is_limited BOOLEAN NOT NULL DEFAULT FALSE" ) if "premium_period_start_at" not in sub_columns: sub_statements.append( "ALTER TABLE subscriptions ADD COLUMN premium_period_start_at TIMESTAMPTZ" ) if "period_start_at" not in sub_columns: sub_statements.append("ALTER TABLE subscriptions ADD COLUMN period_start_at TIMESTAMPTZ") if "is_throttled" not in sub_columns: sub_statements.append( "ALTER TABLE subscriptions ADD COLUMN is_throttled BOOLEAN NOT NULL DEFAULT FALSE" ) if "effective_monthly_price_rub" not in sub_columns: sub_statements.append( "ALTER TABLE subscriptions ADD COLUMN effective_monthly_price_rub NUMERIC" ) if "hwid_device_limit" not in sub_columns: sub_statements.append("ALTER TABLE subscriptions ADD COLUMN hwid_device_limit INTEGER") if "extra_hwid_devices" not in sub_columns: sub_statements.append( "ALTER TABLE subscriptions ADD COLUMN extra_hwid_devices INTEGER NOT NULL DEFAULT 0" ) for stmt in sub_statements: connection.execute(text(stmt)) payment_columns: Set[str] = {col["name"] for col in inspector.get_columns("payments")} payment_statements: List[str] = [] if "sale_mode" not in payment_columns: payment_statements.append("ALTER TABLE payments ADD COLUMN sale_mode VARCHAR") if "tariff_key" not in payment_columns: payment_statements.append("ALTER TABLE payments ADD COLUMN tariff_key VARCHAR") if "purchased_gb" not in payment_columns: payment_statements.append("ALTER TABLE payments ADD COLUMN purchased_gb DOUBLE PRECISION") if "purchased_hwid_devices" not in payment_columns: payment_statements.append("ALTER TABLE payments ADD COLUMN purchased_hwid_devices INTEGER") for stmt in payment_statements: connection.execute(text(stmt)) connection.execute( text( """ CREATE TABLE IF NOT EXISTS traffic_topups ( topup_id SERIAL PRIMARY KEY, subscription_id INTEGER NOT NULL REFERENCES subscriptions(subscription_id), payment_id INTEGER NULL REFERENCES payments(payment_id), purchased_bytes BIGINT NOT NULL, kind VARCHAR NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) """ ) ) connection.execute( text( """ CREATE TABLE IF NOT EXISTS traffic_warnings ( warning_id SERIAL PRIMARY KEY, subscription_id INTEGER NOT NULL REFERENCES subscriptions(subscription_id), period_start_at TIMESTAMPTZ NULL, level INTEGER NOT NULL, traffic_limit_bytes BIGINT NULL, sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT uq_traffic_warning_period_level UNIQUE (subscription_id, period_start_at, level) ) """ # noqa: E501 ) ) connection.execute( text( """ CREATE TABLE IF NOT EXISTS hwid_device_purchases ( purchase_id SERIAL PRIMARY KEY, subscription_id INTEGER NOT NULL REFERENCES subscriptions(subscription_id), payment_id INTEGER NULL REFERENCES payments(payment_id), purchased_devices INTEGER NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) """ ) ) connection.execute( text( """ CREATE TABLE IF NOT EXISTS tariff_changes ( change_id SERIAL PRIMARY KEY, subscription_id INTEGER NOT NULL REFERENCES subscriptions(subscription_id), from_tariff_key VARCHAR NULL, to_tariff_key VARCHAR NOT NULL, mode VARCHAR NOT NULL, payment_id INTEGER NULL REFERENCES payments(payment_id), days_before INTEGER NULL, days_after INTEGER NULL, converted_bytes BIGINT NULL, eff_price_before NUMERIC NULL, eff_price_after NUMERIC NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) """ ) ) for stmt in [ "CREATE INDEX IF NOT EXISTS ix_subscriptions_tariff_key ON subscriptions (tariff_key)", "CREATE INDEX IF NOT EXISTS ix_subscriptions_is_throttled ON subscriptions (is_throttled)", "CREATE INDEX IF NOT EXISTS ix_subscriptions_premium_is_limited ON subscriptions (premium_is_limited)", # noqa: E501 "CREATE INDEX IF NOT EXISTS ix_payments_sale_mode ON payments (sale_mode)", "CREATE INDEX IF NOT EXISTS ix_payments_tariff_key ON payments (tariff_key)", "CREATE INDEX IF NOT EXISTS ix_traffic_topups_subscription_id ON traffic_topups (subscription_id)", # noqa: E501 "CREATE INDEX IF NOT EXISTS ix_traffic_topups_payment_id ON traffic_topups (payment_id)", "CREATE INDEX IF NOT EXISTS ix_traffic_topups_kind ON traffic_topups (kind)", "CREATE INDEX IF NOT EXISTS ix_traffic_warnings_subscription_id ON traffic_warnings (subscription_id)", # noqa: E501 "CREATE INDEX IF NOT EXISTS ix_tariff_changes_subscription_id ON tariff_changes (subscription_id)", # noqa: E501 "CREATE INDEX IF NOT EXISTS ix_hwid_device_purchases_subscription_id ON hwid_device_purchases (subscription_id)", # noqa: E501 "CREATE INDEX IF NOT EXISTS ix_hwid_device_purchases_payment_id ON hwid_device_purchases (payment_id)", # noqa: E501 ]: connection.execute(text(stmt)) def _migration_0009_add_composite_indexes(connection: Connection) -> None: connection.execute( text( """ CREATE INDEX IF NOT EXISTS ix_subscriptions_is_active_end_date ON subscriptions (is_active, end_date) """ ) ) connection.execute( text( """ CREATE INDEX IF NOT EXISTS ix_subscriptions_user_id_is_active ON subscriptions (user_id, is_active) """ ) ) connection.execute( text( """ CREATE INDEX IF NOT EXISTS ix_payments_user_id_status ON payments (user_id, status) """ ) ) def _migration_0014_add_premium_squad_traffic_fields(connection: Connection) -> None: inspector = inspect(connection) sub_columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")} statements: List[str] = [] if "premium_baseline_bytes" not in sub_columns: statements.append( "ALTER TABLE subscriptions ADD COLUMN premium_baseline_bytes BIGINT NOT NULL DEFAULT 0" ) if "premium_topup_balance_bytes" not in sub_columns: statements.append( "ALTER TABLE subscriptions ADD COLUMN premium_topup_balance_bytes BIGINT NOT NULL DEFAULT 0" # noqa: E501 ) if "premium_topup_used_bytes" not in sub_columns: statements.append( "ALTER TABLE subscriptions ADD COLUMN premium_topup_used_bytes BIGINT NOT NULL DEFAULT 0" # noqa: E501 ) if "premium_used_bytes" not in sub_columns: statements.append( "ALTER TABLE subscriptions ADD COLUMN premium_used_bytes BIGINT NOT NULL DEFAULT 0" ) if "premium_is_limited" not in sub_columns: statements.append( "ALTER TABLE subscriptions ADD COLUMN premium_is_limited BOOLEAN NOT NULL DEFAULT FALSE" ) if "premium_period_start_at" not in sub_columns: statements.append( "ALTER TABLE subscriptions ADD COLUMN premium_period_start_at TIMESTAMPTZ" ) for stmt in statements: connection.execute(text(stmt)) connection.execute( text( "CREATE INDEX IF NOT EXISTS ix_subscriptions_premium_is_limited ON subscriptions (premium_is_limited)" # noqa: E501 ) ) def _migration_0015_add_premium_topup_carryover_fields(connection: Connection) -> None: inspector = inspect(connection) sub_columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")} statements: List[str] = [] if "premium_topup_used_bytes" not in sub_columns: statements.append( "ALTER TABLE subscriptions ADD COLUMN premium_topup_used_bytes BIGINT NOT NULL DEFAULT 0" # noqa: E501 ) if "premium_period_start_at" not in sub_columns: statements.append( "ALTER TABLE subscriptions ADD COLUMN premium_period_start_at TIMESTAMPTZ" ) for stmt in statements: connection.execute(text(stmt)) def _migration_0016_add_message_logs_admin_fields(connection: Connection) -> None: inspector = inspect(connection) columns: Set[str] = {col["name"] for col in inspector.get_columns("message_logs")} statements: List[str] = [] if "is_admin_event" not in columns: statements.append( "ALTER TABLE message_logs ADD COLUMN is_admin_event BOOLEAN NOT NULL DEFAULT FALSE" ) if "target_user_id" not in columns: statements.append( "ALTER TABLE message_logs ADD COLUMN target_user_id BIGINT REFERENCES users(user_id)" ) for stmt in statements: connection.execute(text(stmt)) connection.execute( text( "CREATE INDEX IF NOT EXISTS ix_message_logs_target_user_id ON message_logs (target_user_id)" # noqa: E501 ) ) def _migration_0018_add_premium_admin_overrides(connection: Connection) -> None: """Per-subscription overrides letting admins gift extra premium traffic or unlimited access.""" inspector = inspect(connection) sub_columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")} statements: List[str] = [] if "premium_unlimited_override" not in sub_columns: statements.append( "ALTER TABLE subscriptions ADD COLUMN premium_unlimited_override BOOLEAN NOT NULL DEFAULT FALSE" # noqa: E501 ) if "premium_bonus_bytes" not in sub_columns: statements.append( "ALTER TABLE subscriptions ADD COLUMN premium_bonus_bytes BIGINT NOT NULL DEFAULT 0" ) for stmt in statements: connection.execute(text(stmt)) connection.execute( text( "CREATE INDEX IF NOT EXISTS ix_subscriptions_premium_unlimited_override " "ON subscriptions (premium_unlimited_override)" ) ) def _migration_0021_add_regular_unlimited_override(connection: Connection) -> None: inspector = inspect(connection) sub_columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")} if "regular_unlimited_override" not in sub_columns: connection.execute( text( "ALTER TABLE subscriptions ADD COLUMN regular_unlimited_override BOOLEAN NOT NULL DEFAULT FALSE" # noqa: E501 ) ) connection.execute( text( "CREATE INDEX IF NOT EXISTS ix_subscriptions_regular_unlimited_override " "ON subscriptions (regular_unlimited_override)" ) ) def _migration_0020_add_regular_bonus_bytes(connection: Connection) -> None: """Admin-granted extra bytes on main (non-premium) traffic limit, like premium_bonus_bytes.""" inspector = inspect(connection) sub_columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")} if "regular_bonus_bytes" not in sub_columns: connection.execute( text( "ALTER TABLE subscriptions ADD COLUMN regular_bonus_bytes BIGINT NOT NULL DEFAULT 0" ) ) def _migration_0019_clear_subscription_months_for_non_subscription_payments( connection: Connection, ) -> None: """Null out subscription_duration_months for legacy non-subscription payments. Older builds stored the raw `months` callback value into `subscription_duration_months` for every sale_mode, including traffic top-ups and HWID device packs. This polluted CSV exports and made admin log messages render top-ups as "N мес.". The new code only sets the column for subscription sales; this migration aligns historical rows. """ inspector = inspect(connection) table_names = set(inspector.get_table_names()) if "payments" not in table_names: return pay_columns: Set[str] = {col["name"] for col in inspector.get_columns("payments")} if "subscription_duration_months" not in pay_columns or "sale_mode" not in pay_columns: return connection.execute( text( """ UPDATE payments SET subscription_duration_months = NULL WHERE subscription_duration_months IS NOT NULL AND sale_mode IS NOT NULL AND split_part(split_part(sale_mode, '@', 1), '|', 1) <> 'subscription' """ ) ) def _migration_0017_reconcile_legacy_admin_api_schema(connection: Connection) -> None: """Backfill columns required by admin user detail API on legacy databases. Some self-hosted instances were upgraded from older builds where parts of the tariffs/admin schema were missing. This migration is intentionally idempotent and only adds absent columns/indexes. """ inspector = inspect(connection) table_names = set(inspector.get_table_names()) if "subscriptions" in table_names: sub_columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")} sub_statements: List[str] = [] if "tariff_key" not in sub_columns: sub_statements.append("ALTER TABLE subscriptions ADD COLUMN tariff_key VARCHAR") if "tier_baseline_bytes" not in sub_columns: sub_statements.append("ALTER TABLE subscriptions ADD COLUMN tier_baseline_bytes BIGINT") if "topup_balance_bytes" not in sub_columns: sub_statements.append( "ALTER TABLE subscriptions ADD COLUMN topup_balance_bytes BIGINT NOT NULL DEFAULT 0" ) if "premium_baseline_bytes" not in sub_columns: sub_statements.append( "ALTER TABLE subscriptions ADD COLUMN premium_baseline_bytes BIGINT NOT NULL DEFAULT 0" # noqa: E501 ) if "premium_topup_balance_bytes" not in sub_columns: sub_statements.append( "ALTER TABLE subscriptions ADD COLUMN premium_topup_balance_bytes BIGINT NOT NULL DEFAULT 0" # noqa: E501 ) if "premium_topup_used_bytes" not in sub_columns: sub_statements.append( "ALTER TABLE subscriptions ADD COLUMN premium_topup_used_bytes BIGINT NOT NULL DEFAULT 0" # noqa: E501 ) if "premium_used_bytes" not in sub_columns: sub_statements.append( "ALTER TABLE subscriptions ADD COLUMN premium_used_bytes BIGINT NOT NULL DEFAULT 0" ) if "premium_is_limited" not in sub_columns: sub_statements.append( "ALTER TABLE subscriptions ADD COLUMN premium_is_limited BOOLEAN NOT NULL DEFAULT FALSE" # noqa: E501 ) if "is_throttled" not in sub_columns: sub_statements.append( "ALTER TABLE subscriptions ADD COLUMN is_throttled BOOLEAN NOT NULL DEFAULT FALSE" ) for stmt in sub_statements: connection.execute(text(stmt)) if "payments" in table_names: pay_columns: Set[str] = {col["name"] for col in inspector.get_columns("payments")} pay_statements: List[str] = [] if "sale_mode" not in pay_columns: pay_statements.append("ALTER TABLE payments ADD COLUMN sale_mode VARCHAR") if "tariff_key" not in pay_columns: pay_statements.append("ALTER TABLE payments ADD COLUMN tariff_key VARCHAR") if "purchased_gb" not in pay_columns: pay_statements.append("ALTER TABLE payments ADD COLUMN purchased_gb DOUBLE PRECISION") if "purchased_hwid_devices" not in pay_columns: pay_statements.append("ALTER TABLE payments ADD COLUMN purchased_hwid_devices INTEGER") for stmt in pay_statements: connection.execute(text(stmt)) if "message_logs" in table_names: msg_columns: Set[str] = {col["name"] for col in inspector.get_columns("message_logs")} msg_statements: List[str] = [] if "is_admin_event" not in msg_columns: msg_statements.append( "ALTER TABLE message_logs ADD COLUMN is_admin_event BOOLEAN NOT NULL DEFAULT FALSE" ) if "target_user_id" not in msg_columns: msg_statements.append( "ALTER TABLE message_logs ADD COLUMN target_user_id BIGINT REFERENCES users(user_id)" # noqa: E501 ) for stmt in msg_statements: connection.execute(text(stmt)) connection.execute( text( "CREATE INDEX IF NOT EXISTS ix_message_logs_target_user_id ON message_logs (target_user_id)" # noqa: E501 ) ) def _migration_0022_add_indexes_for_admin_reports(connection: Connection) -> None: connection.execute( text( "CREATE INDEX IF NOT EXISTS ix_payments_status_created_at " "ON payments (status, created_at)" ) ) connection.execute( text( "CREATE INDEX IF NOT EXISTS ix_message_logs_timestamp ON message_logs (timestamp DESC)" ) ) def _migration_0023_add_email_password_auth_fields(connection: Connection) -> None: inspector = inspect(connection) columns: Set[str] = {col["name"] for col in inspector.get_columns("users")} if "password_hash" not in columns: connection.execute(text("ALTER TABLE users ADD COLUMN password_hash VARCHAR")) if "password_set_at" not in columns: connection.execute(text("ALTER TABLE users ADD COLUMN password_set_at TIMESTAMPTZ")) def _migration_0024_add_support_tickets(connection: Connection) -> None: connection.execute( text( """ CREATE TABLE IF NOT EXISTS support_tickets ( ticket_id SERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(user_id), subject VARCHAR(160) NOT NULL, category VARCHAR(32) NOT NULL DEFAULT 'other', priority VARCHAR(16) NOT NULL DEFAULT 'normal', status VARCHAR(24) NOT NULL DEFAULT 'open', assigned_admin_id BIGINT NULL, last_message_at TIMESTAMPTZ DEFAULT NOW(), last_message_role VARCHAR(16) NULL, unread_user_count INTEGER NOT NULL DEFAULT 0, unread_admin_count INTEGER NOT NULL DEFAULT 0, admin_last_notified_at TIMESTAMPTZ NULL, admin_last_emailed_at TIMESTAMPTZ NULL, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ NULL, closed_at TIMESTAMPTZ NULL, closed_by_admin_id BIGINT NULL ) """ ) ) connection.execute( text( """ CREATE TABLE IF NOT EXISTS support_ticket_messages ( message_id SERIAL PRIMARY KEY, ticket_id INTEGER NOT NULL REFERENCES support_tickets(ticket_id) ON DELETE CASCADE, author_role VARCHAR(16) NOT NULL, author_user_id BIGINT NULL REFERENCES users(user_id), body TEXT NOT NULL, is_internal_note BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ DEFAULT NOW(), read_by_user_at TIMESTAMPTZ NULL, read_by_admin_at TIMESTAMPTZ NULL ) """ ) ) inspector = inspect(connection) ticket_columns: Set[str] = {col["name"] for col in inspector.get_columns("support_tickets")} ticket_column_sql = { "user_id": "BIGINT NOT NULL REFERENCES users(user_id)", "subject": "VARCHAR(160) NOT NULL DEFAULT ''", "category": "VARCHAR(32) NOT NULL DEFAULT 'other'", "priority": "VARCHAR(16) NOT NULL DEFAULT 'normal'", "status": "VARCHAR(24) NOT NULL DEFAULT 'open'", "assigned_admin_id": "BIGINT NULL", "last_message_at": "TIMESTAMPTZ DEFAULT NOW()", "last_message_role": "VARCHAR(16) NULL", "unread_user_count": "INTEGER NOT NULL DEFAULT 0", "unread_admin_count": "INTEGER NOT NULL DEFAULT 0", "admin_last_notified_at": "TIMESTAMPTZ NULL", "admin_last_emailed_at": "TIMESTAMPTZ NULL", "created_at": "TIMESTAMPTZ DEFAULT NOW()", "updated_at": "TIMESTAMPTZ NULL", "closed_at": "TIMESTAMPTZ NULL", "closed_by_admin_id": "BIGINT NULL", } for column, definition in ticket_column_sql.items(): if column not in ticket_columns: connection.execute( text(f"ALTER TABLE support_tickets ADD COLUMN {column} {definition}") ) message_columns: Set[str] = { col["name"] for col in inspector.get_columns("support_ticket_messages") } message_column_sql = { "ticket_id": "INTEGER NOT NULL REFERENCES support_tickets(ticket_id) ON DELETE CASCADE", "author_role": "VARCHAR(16) NOT NULL DEFAULT 'user'", "author_user_id": "BIGINT NULL REFERENCES users(user_id)", "body": "TEXT NOT NULL DEFAULT ''", "is_internal_note": "BOOLEAN NOT NULL DEFAULT FALSE", "created_at": "TIMESTAMPTZ DEFAULT NOW()", "read_by_user_at": "TIMESTAMPTZ NULL", "read_by_admin_at": "TIMESTAMPTZ NULL", } for column, definition in message_column_sql.items(): if column not in message_columns: connection.execute( text(f"ALTER TABLE support_ticket_messages ADD COLUMN {column} {definition}") ) index_statements = [ "CREATE INDEX IF NOT EXISTS ix_support_tickets_user_id ON support_tickets (user_id)", "CREATE INDEX IF NOT EXISTS ix_support_tickets_category ON support_tickets (category)", "CREATE INDEX IF NOT EXISTS ix_support_tickets_priority ON support_tickets (priority)", "CREATE INDEX IF NOT EXISTS ix_support_tickets_status ON support_tickets (status)", "CREATE INDEX IF NOT EXISTS ix_support_tickets_assigned_admin_id ON support_tickets (assigned_admin_id)", # noqa: E501 "CREATE INDEX IF NOT EXISTS ix_support_tickets_last_message_at ON support_tickets (last_message_at)", # noqa: E501 "CREATE INDEX IF NOT EXISTS ix_support_tickets_status_last_msg ON support_tickets (status, last_message_at)", # noqa: E501 "CREATE INDEX IF NOT EXISTS ix_support_ticket_messages_ticket_id ON support_ticket_messages (ticket_id)", # noqa: E501 "CREATE INDEX IF NOT EXISTS ix_support_ticket_messages_author_user_id ON support_ticket_messages (author_user_id)", # noqa: E501 "CREATE INDEX IF NOT EXISTS ix_support_ticket_messages_is_internal_note ON support_ticket_messages (is_internal_note)", # noqa: E501 "CREATE INDEX IF NOT EXISTS ix_support_ticket_messages_created_at ON support_ticket_messages (created_at)", # noqa: E501 ] for stmt in index_statements: connection.execute(text(stmt)) def _migration_0025_add_support_notification_timestamps(connection: Connection) -> None: inspector = inspect(connection) ticket_columns: Set[str] = {col["name"] for col in inspector.get_columns("support_tickets")} column_sql = { "admin_last_notified_at": "TIMESTAMPTZ NULL", "admin_last_emailed_at": "TIMESTAMPTZ NULL", } for column, definition in column_sql.items(): if column not in ticket_columns: connection.execute( text(f"ALTER TABLE support_tickets ADD COLUMN {column} {definition}") ) def _migration_0026_add_lifetime_traffic_synced_at(connection: Connection) -> None: inspector = inspect(connection) columns: Set[str] = {col["name"] for col in inspector.get_columns("users")} if "lifetime_used_traffic_synced_at" not in columns: connection.execute( text("ALTER TABLE users ADD COLUMN lifetime_used_traffic_synced_at TIMESTAMPTZ") ) def _migration_0027_add_subscription_install_share_token(connection: Connection) -> None: inspector = inspect(connection) columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")} if "install_share_token" not in columns: connection.execute( text("ALTER TABLE subscriptions ADD COLUMN install_share_token VARCHAR(32)") ) connection.execute( text( """ CREATE UNIQUE INDEX IF NOT EXISTS uq_subscriptions_install_share_token ON subscriptions (install_share_token) WHERE install_share_token IS NOT NULL """ ) ) def _migration_0028_add_locale_overrides(connection: Connection) -> None: connection.execute( text( """ CREATE TABLE IF NOT EXISTS locale_overrides ( lang VARCHAR(16) NOT NULL, key VARCHAR(255) NOT NULL, value TEXT NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_by BIGINT, PRIMARY KEY (lang, key) ) """ ) ) def _migration_0029_add_hwid_device_purchase_validity(connection: Connection) -> None: inspector = inspect(connection) table_names = set(inspector.get_table_names()) if "hwid_device_purchases" not in table_names or "subscriptions" not in table_names: return columns: Set[str] = {col["name"] for col in inspector.get_columns("hwid_device_purchases")} if "valid_from" not in columns: connection.execute( text("ALTER TABLE hwid_device_purchases ADD COLUMN valid_from TIMESTAMPTZ") ) if "valid_until" not in columns: connection.execute( text("ALTER TABLE hwid_device_purchases ADD COLUMN valid_until TIMESTAMPTZ") ) connection.execute( text( """ UPDATE hwid_device_purchases hp SET valid_from = COALESCE(hp.valid_from, hp.created_at, s.start_date, NOW()), valid_until = COALESCE(hp.valid_until, s.end_date) FROM subscriptions s WHERE hp.subscription_id = s.subscription_id AND (hp.valid_from IS NULL OR hp.valid_until IS NULL) """ ) ) connection.execute( text( """ INSERT INTO hwid_device_purchases ( subscription_id, payment_id, purchased_devices, valid_from, valid_until ) SELECT s.subscription_id, NULL, s.extra_hwid_devices, COALESCE(s.start_date, NOW()), s.end_date FROM subscriptions s WHERE COALESCE(s.extra_hwid_devices, 0) > 0 AND s.end_date IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM hwid_device_purchases hp WHERE hp.subscription_id = s.subscription_id ) """ ) ) connection.execute( text( "CREATE INDEX IF NOT EXISTS ix_hwid_device_purchases_subscription_window " "ON hwid_device_purchases (subscription_id, valid_from, valid_until)" ) ) def _migration_0030_add_hwid_pricing_metadata(connection: Connection) -> None: inspector = inspect(connection) table_names = set(inspector.get_table_names()) if "payments" in table_names: payment_columns: Set[str] = {col["name"] for col in inspector.get_columns("payments")} payment_additions = { "hwid_valid_from": "TIMESTAMPTZ", "hwid_valid_until": "TIMESTAMPTZ", "hwid_pricing_period_months": "INTEGER", "hwid_proration_ratio": "DOUBLE PRECISION", "hwid_full_price": "DOUBLE PRECISION", } for column, ddl_type in payment_additions.items(): if column not in payment_columns: connection.execute(text(f"ALTER TABLE payments ADD COLUMN {column} {ddl_type}")) if "tariff_changes" in table_names: change_columns: Set[str] = {col["name"] for col in inspector.get_columns("tariff_changes")} change_additions = { "converted_hwid_value_rub": "NUMERIC", "converted_hwid_days": "INTEGER", } for column, ddl_type in change_additions.items(): if column not in change_columns: connection.execute( text(f"ALTER TABLE tariff_changes ADD COLUMN {column} {ddl_type}") ) def _migration_0031_add_subscription_notifications(connection: Connection) -> None: connection.execute( text( """ CREATE TABLE IF NOT EXISTS subscription_notifications ( notification_id SERIAL PRIMARY KEY, subscription_id INTEGER NOT NULL REFERENCES subscriptions(subscription_id), notification_key VARCHAR(64) NOT NULL, sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT uq_subscription_notification_key UNIQUE ( subscription_id, notification_key ) ) """ ) ) connection.execute( text( """ CREATE INDEX IF NOT EXISTS ix_subscription_notifications_subscription_id ON subscription_notifications (subscription_id) """ ) ) connection.execute( text( """ CREATE INDEX IF NOT EXISTS ix_subscription_notifications_notification_key ON subscription_notifications (notification_key) """ ) ) MIGRATIONS: List[Migration] = [ Migration( id="0001_add_channel_subscription_fields", description="Add columns to track required channel subscription verification", upgrade=_migration_0001_add_channel_subscription_fields, ), Migration( id="0002_add_referral_code", description="Store short referral codes for users and backfill existing rows", upgrade=_migration_0002_add_referral_code, ), Migration( id="0003_normalize_referral_codes", description="Normalize referral codes to uppercase for consistent lookups", upgrade=_migration_0003_normalize_referral_codes, ), Migration( id="0004_add_lifetime_used_traffic", description="Store lifetime traffic usage for users", upgrade=_migration_0004_add_lifetime_used_traffic, ), Migration( id="0005_add_email_auth_fields", description="Add email login identities and verification codes", upgrade=_migration_0005_add_email_auth_fields, ), Migration( id="0006_add_security_throttles", description="Add generic lockout tracking for brute-force protection", upgrade=_migration_0006_add_security_throttles, ), Migration( id="0007_add_telegram_photo_url", description="Store Telegram profile photo URLs for linked users", upgrade=_migration_0007_add_telegram_photo_url, ), Migration( id="0008_add_email_verification_code_status", description="Track superseded email verification codes explicitly", upgrade=_migration_0008_add_email_verification_code_status, ), Migration( id="0009_add_composite_indexes", description="Add composite indexes for subscription and payment lookups", upgrade=_migration_0009_add_composite_indexes, ), Migration( id="0010_add_email_magic_token_hash", description="Store hashed magic-link tokens for email login deeplinks", upgrade=_migration_0010_add_email_magic_token_hash, ), Migration( id="0011_add_user_telegram_avatars", description="Cache compact Telegram profile avatars for WebApp profiles", upgrade=_migration_0011_add_user_telegram_avatars, ), Migration( id="0012_add_tariffs_schema", description="Add tariff catalog columns and traffic accounting tables", upgrade=_migration_0012_add_tariffs_schema, ), Migration( id="0013_add_app_setting_overrides", description="Persisted runtime overrides for application settings managed via admin webapp", upgrade=lambda connection: connection.execute( text( """ CREATE TABLE IF NOT EXISTS app_setting_overrides ( key VARCHAR(128) PRIMARY KEY, value TEXT, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_by BIGINT ) """ ) ), ), Migration( id="0014_add_premium_squad_traffic_fields", description="Track premium squad traffic limits and top-ups per subscription", upgrade=_migration_0014_add_premium_squad_traffic_fields, ), Migration( id="0015_add_premium_topup_carryover_fields", description="Track premium top-up usage within the current monthly period", upgrade=_migration_0015_add_premium_topup_carryover_fields, ), Migration( id="0016_add_message_logs_admin_fields", description="Add admin-related message log fields used by admin user detail APIs", upgrade=_migration_0016_add_message_logs_admin_fields, ), Migration( id="0017_reconcile_legacy_admin_api_schema", description="Reconcile legacy DB schema for admin user detail endpoint compatibility", upgrade=_migration_0017_reconcile_legacy_admin_api_schema, ), Migration( id="0018_add_premium_admin_overrides", description="Per-subscription admin overrides for premium traffic (unlimited toggle + bonus bytes)", # noqa: E501 upgrade=_migration_0018_add_premium_admin_overrides, ), Migration( id="0019_clear_subscription_months_for_non_subscription_payments", description="Backfill: null out subscription_duration_months for legacy traffic/topup/hwid payments", # noqa: E501 upgrade=_migration_0019_clear_subscription_months_for_non_subscription_payments, ), Migration( id="0020_add_regular_bonus_bytes", description="Per-subscription admin bonus bytes on regular (main) traffic limit", upgrade=_migration_0020_add_regular_bonus_bytes, ), Migration( id="0021_add_regular_unlimited_override", description="Admin toggle for effectively unlimited main traffic limit", upgrade=_migration_0021_add_regular_unlimited_override, ), Migration( id="0022_add_indexes_for_admin_reports", description="Indexes to speed up financial stats and admin log queries", upgrade=_migration_0022_add_indexes_for_admin_reports, ), Migration( id="0023_add_email_password_auth_fields", description="Store hashed passwords for optional email password login", upgrade=_migration_0023_add_email_password_auth_fields, ), Migration( id="0024_add_support_tickets", description="Add support ticket inbox and messages", upgrade=_migration_0024_add_support_tickets, ), Migration( id="0025_add_support_notification_timestamps", description="Track support ticket admin notification cooldown timestamps", upgrade=_migration_0025_add_support_notification_timestamps, ), Migration( id="0026_add_lifetime_traffic_synced_at", description="Track when lifetime traffic usage was last synced from panel", upgrade=_migration_0026_add_lifetime_traffic_synced_at, ), Migration( id="0027_add_subscription_install_share_token", description="Add stable public share tokens for install instructions", upgrade=_migration_0027_add_subscription_install_share_token, ), Migration( id="0028_add_locale_overrides", description="Persist runtime overrides for localization strings", upgrade=_migration_0028_add_locale_overrides, ), Migration( id="0029_add_hwid_device_purchase_validity", description="Track validity windows for HWID device top-ups", upgrade=_migration_0029_add_hwid_device_purchase_validity, ), Migration( id="0030_add_hwid_pricing_metadata", description="Persist quoted HWID top-up pricing windows and conversion audit", upgrade=_migration_0030_add_hwid_pricing_metadata, ), Migration( id="0031_add_subscription_notifications", description="Track sent subscription notification stages", upgrade=_migration_0031_add_subscription_notifications, ), ] def run_database_migrations(connection: Connection) -> None: """ Apply pending migrations sequentially. Already applied revisions are skipped. """ _ensure_migrations_table(connection) applied_revisions: Set[str] = { row[0] for row in connection.execute(text("SELECT id FROM schema_migrations")) } for migration in MIGRATIONS: if migration.id in applied_revisions: continue logging.info("Migrator: applying %s – %s", migration.id, migration.description) try: with connection.begin_nested(): migration.upgrade(connection) connection.execute( text("INSERT INTO schema_migrations (id) VALUES (:revision)"), {"revision": migration.id}, ) except Exception as exc: logging.error( "Migrator: failed to apply %s (%s)", migration.id, migration.description, exc_info=True, ) raise exc else: logging.info("Migrator: migration %s applied successfully", migration.id)