From a10b9f0ad7cc40d41a6d01850db1b175cab97ab6 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 20 Sep 2025 08:23:57 +0300 Subject: [PATCH 1/3] Expand universal migration to provision promo groups --- app/database/universal_migration.py | 395 +++++++++++++++++- ...5f3a3f5a4d_add_promo_groups_and_user_fk.py | 224 ++++++++++ ...35c6bd8f_add_paid_price_to_subscription.py | 28 +- .../5d1f1f8b2e9a_add_advertising_campaigns.py | 178 +++++--- ...d1e338eb45_add_sent_notifications_table.py | 53 ++- ...72f3d_add_cascade_to_sent_notifications.py | 83 +++- 6 files changed, 878 insertions(+), 83 deletions(-) create mode 100644 migrations/alembic/versions/1f5f3a3f5a4d_add_promo_groups_and_user_fk.py diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 1a2ecafc..d186298d 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -74,6 +74,104 @@ async def check_column_exists(table_name: str, column_name: str) -> bool: logger.error(f"Ошибка проверки существования колонки {column_name}: {e}") return False + +async def check_constraint_exists(table_name: str, constraint_name: str) -> bool: + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == "postgresql": + result = await conn.execute( + text( + """ + SELECT 1 + FROM information_schema.table_constraints + WHERE table_schema = 'public' + AND table_name = :table_name + AND constraint_name = :constraint_name + """ + ), + {"table_name": table_name, "constraint_name": constraint_name}, + ) + return result.fetchone() is not None + + if db_type == "mysql": + result = await conn.execute( + text( + """ + SELECT 1 + FROM information_schema.table_constraints + WHERE table_schema = DATABASE() + AND table_name = :table_name + AND constraint_name = :constraint_name + """ + ), + {"table_name": table_name, "constraint_name": constraint_name}, + ) + return result.fetchone() is not None + + if db_type == "sqlite": + result = await conn.execute(text(f"PRAGMA foreign_key_list({table_name})")) + rows = result.fetchall() + return any(row[5] == constraint_name for row in rows) + + return False + + except Exception as e: + logger.error( + f"Ошибка проверки существования ограничения {constraint_name} для {table_name}: {e}" + ) + return False + + +async def check_index_exists(table_name: str, index_name: str) -> bool: + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == "postgresql": + result = await conn.execute( + text( + """ + SELECT 1 + FROM pg_indexes + WHERE schemaname = 'public' + AND tablename = :table_name + AND indexname = :index_name + """ + ), + {"table_name": table_name, "index_name": index_name}, + ) + return result.fetchone() is not None + + if db_type == "mysql": + result = await conn.execute( + text( + """ + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = :table_name + AND index_name = :index_name + """ + ), + {"table_name": table_name, "index_name": index_name}, + ) + return result.fetchone() is not None + + if db_type == "sqlite": + result = await conn.execute(text(f"PRAGMA index_list({table_name})")) + rows = result.fetchall() + return any(row[1] == index_name for row in rows) + + return False + + except Exception as e: + logger.error( + f"Ошибка проверки существования индекса {index_name} для {table_name}: {e}" + ) + return False + async def create_cryptobot_payments_table(): table_exists = await check_table_exists('cryptobot_payments') if table_exists: @@ -248,6 +346,276 @@ async def create_user_messages_table(): logger.error(f"Ошибка создания таблицы user_messages: {e}") return False + +async def ensure_promo_groups_setup(): + logger.info("=== НАСТРОЙКА ПРОМО ГРУПП ===") + + try: + promo_table_exists = await check_table_exists("promo_groups") + + async with engine.begin() as conn: + db_type = await get_database_type() + + if not promo_table_exists: + if db_type == "sqlite": + await conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS promo_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(255) NOT NULL, + server_discount_percent INTEGER NOT NULL DEFAULT 0, + traffic_discount_percent INTEGER NOT NULL DEFAULT 0, + device_discount_percent INTEGER NOT NULL DEFAULT 0, + is_default BOOLEAN NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + await conn.execute( + text( + "CREATE UNIQUE INDEX IF NOT EXISTS uq_promo_groups_name ON promo_groups(name)" + ) + ) + elif db_type == "postgresql": + await conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS promo_groups ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + server_discount_percent INTEGER NOT NULL DEFAULT 0, + traffic_discount_percent INTEGER NOT NULL DEFAULT 0, + device_discount_percent INTEGER NOT NULL DEFAULT 0, + is_default BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_promo_groups_name UNIQUE (name) + ) + """ + ) + ) + elif db_type == "mysql": + await conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS promo_groups ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + server_discount_percent INT NOT NULL DEFAULT 0, + traffic_discount_percent INT NOT NULL DEFAULT 0, + device_discount_percent INT NOT NULL DEFAULT 0, + is_default TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_promo_groups_name (name) + ) ENGINE=InnoDB + """ + ) + ) + else: + logger.error(f"Неподдерживаемый тип БД для promo_groups: {db_type}") + return False + + logger.info("Создана таблица promo_groups") + + if db_type == "postgresql" and not await check_constraint_exists( + "promo_groups", "uq_promo_groups_name" + ): + try: + await conn.execute( + text( + "ALTER TABLE promo_groups ADD CONSTRAINT uq_promo_groups_name UNIQUE (name)" + ) + ) + except Exception as e: + logger.warning( + f"Не удалось добавить уникальное ограничение uq_promo_groups_name: {e}" + ) + + column_exists = await check_column_exists("users", "promo_group_id") + + if not column_exists: + if db_type == "sqlite": + await conn.execute(text("ALTER TABLE users ADD COLUMN promo_group_id INTEGER")) + elif db_type == "postgresql": + await conn.execute(text("ALTER TABLE users ADD COLUMN promo_group_id INTEGER")) + elif db_type == "mysql": + await conn.execute(text("ALTER TABLE users ADD COLUMN promo_group_id INT")) + else: + logger.error(f"Неподдерживаемый тип БД для promo_group_id: {db_type}") + return False + + logger.info("Добавлена колонка users.promo_group_id") + + index_exists = await check_index_exists("users", "ix_users_promo_group_id") + + if not index_exists: + try: + if db_type == "sqlite": + await conn.execute( + text("CREATE INDEX IF NOT EXISTS ix_users_promo_group_id ON users(promo_group_id)") + ) + elif db_type == "postgresql": + await conn.execute( + text("CREATE INDEX IF NOT EXISTS ix_users_promo_group_id ON users(promo_group_id)") + ) + elif db_type == "mysql": + await conn.execute( + text("CREATE INDEX ix_users_promo_group_id ON users(promo_group_id)") + ) + logger.info("Создан индекс ix_users_promo_group_id") + except Exception as e: + logger.warning(f"Не удалось создать индекс ix_users_promo_group_id: {e}") + + default_group_name = "Базовый юзер" + default_group_id = None + + result = await conn.execute( + text( + "SELECT id, is_default FROM promo_groups WHERE name = :name LIMIT 1" + ), + {"name": default_group_name}, + ) + row = result.fetchone() + + if row: + default_group_id = row[0] + if not row[1]: + await conn.execute( + text( + "UPDATE promo_groups SET is_default = :is_default WHERE id = :group_id" + ), + {"is_default": True, "group_id": default_group_id}, + ) + else: + result = await conn.execute( + text( + "SELECT id FROM promo_groups WHERE is_default = :is_default LIMIT 1" + ), + {"is_default": True}, + ) + existing_default = result.fetchone() + + if existing_default: + default_group_id = existing_default[0] + else: + await conn.execute( + text( + """ + INSERT INTO promo_groups ( + name, + server_discount_percent, + traffic_discount_percent, + device_discount_percent, + is_default + ) VALUES (:name, 0, 0, 0, :is_default) + """ + ), + {"name": default_group_name, "is_default": True}, + ) + + result = await conn.execute( + text( + "SELECT id FROM promo_groups WHERE name = :name LIMIT 1" + ), + {"name": default_group_name}, + ) + row = result.fetchone() + default_group_id = row[0] if row else None + + if default_group_id is None: + logger.error("Не удалось определить идентификатор базовой промо-группы") + return False + + await conn.execute( + text( + """ + UPDATE users + SET promo_group_id = :group_id + WHERE promo_group_id IS NULL + """ + ), + {"group_id": default_group_id}, + ) + + if db_type == "postgresql": + constraint_exists = await check_constraint_exists( + "users", "fk_users_promo_group_id_promo_groups" + ) + if not constraint_exists: + try: + await conn.execute( + text( + """ + ALTER TABLE users + ADD CONSTRAINT fk_users_promo_group_id_promo_groups + FOREIGN KEY (promo_group_id) + REFERENCES promo_groups(id) + ON DELETE RESTRICT + """ + ) + ) + logger.info("Добавлен внешний ключ users -> promo_groups") + except Exception as e: + logger.warning( + f"Не удалось добавить внешний ключ users.promo_group_id: {e}" + ) + + try: + await conn.execute( + text( + "ALTER TABLE users ALTER COLUMN promo_group_id SET NOT NULL" + ) + ) + except Exception as e: + logger.warning( + f"Не удалось сделать users.promo_group_id NOT NULL: {e}" + ) + + elif db_type == "mysql": + constraint_exists = await check_constraint_exists( + "users", "fk_users_promo_group_id_promo_groups" + ) + if not constraint_exists: + try: + await conn.execute( + text( + """ + ALTER TABLE users + ADD CONSTRAINT fk_users_promo_group_id_promo_groups + FOREIGN KEY (promo_group_id) + REFERENCES promo_groups(id) + ON DELETE RESTRICT + """ + ) + ) + logger.info("Добавлен внешний ключ users -> promo_groups") + except Exception as e: + logger.warning( + f"Не удалось добавить внешний ключ users.promo_group_id: {e}" + ) + + try: + await conn.execute( + text( + "ALTER TABLE users MODIFY promo_group_id INT NOT NULL" + ) + ) + except Exception as e: + logger.warning( + f"Не удалось сделать users.promo_group_id NOT NULL: {e}" + ) + + logger.info("✅ Промо группы настроены") + return True + + except Exception as e: + logger.error(f"Ошибка настройки промо групп: {e}") + return False + async def add_welcome_text_is_enabled_column(): column_exists = await check_column_exists('welcome_texts', 'is_enabled') if column_exists: @@ -688,7 +1056,14 @@ async def run_universal_migration(): logger.info("✅ Медиа поля в broadcast_history готовы") else: logger.warning("⚠️ Проблемы с добавлением медиа полей") - + + logger.info("=== НАСТРОЙКА ПРОМО ГРУПП ===") + promo_groups_ready = await ensure_promo_groups_setup() + if promo_groups_ready: + logger.info("✅ Промо группы готовы") + else: + logger.warning("⚠️ Проблемы с настройкой промо групп") + logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===") fk_updated = await fix_foreign_keys_for_user_deletion() if fk_updated: @@ -756,10 +1131,12 @@ async def check_migration_status(): "cryptobot_table": False, "user_messages_table": False, "welcome_texts_table": False, - "welcome_texts_is_enabled_column": False, - "broadcast_history_media_fields": False, + "welcome_texts_is_enabled_column": False, + "broadcast_history_media_fields": False, "subscription_duplicates": False, - "subscription_conversions_table": False + "subscription_conversions_table": False, + "promo_groups_table": False, + "users_promo_group_column": False } status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup') @@ -768,8 +1145,10 @@ async def check_migration_status(): status["user_messages_table"] = await check_table_exists('user_messages') status["welcome_texts_table"] = await check_table_exists('welcome_texts') status["subscription_conversions_table"] = await check_table_exists('subscription_conversions') - + status["promo_groups_table"] = await check_table_exists('promo_groups') + status["welcome_texts_is_enabled_column"] = await check_column_exists('welcome_texts', 'is_enabled') + status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id') media_fields_exist = ( await check_column_exists('broadcast_history', 'has_media') and @@ -797,9 +1176,11 @@ async def check_migration_status(): "user_messages_table": "Таблица пользовательских сообщений", "welcome_texts_table": "Таблица приветственных текстов", "welcome_texts_is_enabled_column": "Поле is_enabled в welcome_texts", - "broadcast_history_media_fields": "Медиа поля в broadcast_history", + "broadcast_history_media_fields": "Медиа поля в broadcast_history", "subscription_conversions_table": "Таблица конверсий подписок", - "subscription_duplicates": "Отсутствие дубликатов подписок" + "subscription_duplicates": "Отсутствие дубликатов подписок", + "promo_groups_table": "Таблица промо-групп", + "users_promo_group_column": "Колонка promo_group_id у пользователей" } for check_key, check_status in status.items(): diff --git a/migrations/alembic/versions/1f5f3a3f5a4d_add_promo_groups_and_user_fk.py b/migrations/alembic/versions/1f5f3a3f5a4d_add_promo_groups_and_user_fk.py new file mode 100644 index 00000000..b16ac8c1 --- /dev/null +++ b/migrations/alembic/versions/1f5f3a3f5a4d_add_promo_groups_and_user_fk.py @@ -0,0 +1,224 @@ +"""add promo groups table and link users""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +PROMO_GROUPS_TABLE = "promo_groups" +USERS_TABLE = "users" +PROMO_GROUP_COLUMN = "promo_group_id" +PROMO_GROUP_INDEX = "ix_users_promo_group_id" +PROMO_GROUP_FK = "fk_users_promo_group_id_promo_groups" +DEFAULT_PROMO_GROUP_NAME = "Базовый юзер" + + +def _table_exists(inspector: sa.Inspector, table_name: str) -> bool: + return table_name in inspector.get_table_names() + + +def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool: + return any(col["name"] == column_name for col in inspector.get_columns(table_name)) + + +def _index_exists(inspector: sa.Inspector, table_name: str, index_name: str) -> bool: + return any(index["name"] == index_name for index in inspector.get_indexes(table_name)) + + +def _foreign_key_exists(inspector: sa.Inspector, table_name: str, fk_name: str) -> bool: + return any(fk["name"] == fk_name for fk in inspector.get_foreign_keys(table_name)) + +revision: str = "1f5f3a3f5a4d" +down_revision: Union[str, None] = "cbd1be472f3d" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if not _table_exists(inspector, PROMO_GROUPS_TABLE): + op.create_table( + PROMO_GROUPS_TABLE, + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column( + "server_discount_percent", + sa.Integer(), + nullable=False, + server_default=sa.text("0"), + ), + sa.Column( + "traffic_discount_percent", + sa.Integer(), + nullable=False, + server_default=sa.text("0"), + ), + sa.Column( + "device_discount_percent", + sa.Integer(), + nullable=False, + server_default=sa.text("0"), + ), + sa.Column( + "is_default", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + sa.Column( + "created_at", + sa.DateTime(), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(), + nullable=False, + server_default=sa.func.now(), + ), + sa.UniqueConstraint("name", name="uq_promo_groups_name"), + ) + inspector = sa.inspect(bind) + + if not _column_exists(inspector, USERS_TABLE, PROMO_GROUP_COLUMN): + op.add_column( + USERS_TABLE, + sa.Column(PROMO_GROUP_COLUMN, sa.Integer(), nullable=True), + ) + inspector = sa.inspect(bind) + + if _column_exists(inspector, USERS_TABLE, PROMO_GROUP_COLUMN): + if not _index_exists(inspector, USERS_TABLE, PROMO_GROUP_INDEX): + op.create_index(PROMO_GROUP_INDEX, USERS_TABLE, [PROMO_GROUP_COLUMN]) + + inspector = sa.inspect(bind) + if not _foreign_key_exists(inspector, USERS_TABLE, PROMO_GROUP_FK): + op.create_foreign_key( + PROMO_GROUP_FK, + USERS_TABLE, + PROMO_GROUPS_TABLE, + [PROMO_GROUP_COLUMN], + ["id"], + ondelete="RESTRICT", + ) + + inspector = sa.inspect(bind) + if not _table_exists(inspector, PROMO_GROUPS_TABLE) or not _column_exists( + inspector, USERS_TABLE, PROMO_GROUP_COLUMN + ): + return + + promo_groups_table = sa.table( + PROMO_GROUPS_TABLE, + sa.column("id", sa.Integer()), + sa.column("name", sa.String()), + sa.column("server_discount_percent", sa.Integer()), + sa.column("traffic_discount_percent", sa.Integer()), + sa.column("device_discount_percent", sa.Integer()), + sa.column("is_default", sa.Boolean()), + ) + + connection = bind + existing_named_group = ( + connection.execute( + sa.select( + promo_groups_table.c.id, + promo_groups_table.c.is_default, + ) + .where(promo_groups_table.c.name == DEFAULT_PROMO_GROUP_NAME) + .limit(1) + ) + .mappings() + .first() + ) + + if existing_named_group: + default_group_id = existing_named_group["id"] + if not existing_named_group["is_default"]: + connection.execute( + sa.update(promo_groups_table) + .where(promo_groups_table.c.id == default_group_id) + .values(is_default=True) + ) + else: + default_group_id = connection.execute( + sa.select(promo_groups_table.c.id) + .where(promo_groups_table.c.is_default.is_(True)) + .limit(1) + ).scalar_one_or_none() + + if default_group_id is None: + default_group_id = connection.execute( + sa.insert(promo_groups_table) + .values( + name=DEFAULT_PROMO_GROUP_NAME, + server_discount_percent=0, + traffic_discount_percent=0, + device_discount_percent=0, + is_default=True, + ) + .returning(promo_groups_table.c.id) + ).scalar_one() + + users_table = sa.table( + USERS_TABLE, + sa.column("promo_group_id", sa.Integer()), + ) + connection.execute( + sa.update(users_table) + .where(users_table.c.promo_group_id.is_(None)) + .values(promo_group_id=default_group_id) + ) + + inspector = sa.inspect(bind) + column_info = next( + (col for col in inspector.get_columns(USERS_TABLE) if col["name"] == PROMO_GROUP_COLUMN), + None, + ) + if column_info and column_info.get("nullable", True): + op.alter_column( + USERS_TABLE, + PROMO_GROUP_COLUMN, + existing_type=sa.Integer(), + nullable=False, + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if _column_exists(inspector, USERS_TABLE, PROMO_GROUP_COLUMN): + column_info = next( + ( + col + for col in inspector.get_columns(USERS_TABLE) + if col["name"] == PROMO_GROUP_COLUMN + ), + None, + ) + if column_info and not column_info.get("nullable", False): + op.alter_column( + USERS_TABLE, + PROMO_GROUP_COLUMN, + existing_type=sa.Integer(), + nullable=True, + ) + + inspector = sa.inspect(bind) + if _foreign_key_exists(inspector, USERS_TABLE, PROMO_GROUP_FK): + op.drop_constraint(PROMO_GROUP_FK, USERS_TABLE, type_="foreignkey") + + inspector = sa.inspect(bind) + if _index_exists(inspector, USERS_TABLE, PROMO_GROUP_INDEX): + op.drop_index(PROMO_GROUP_INDEX, table_name=USERS_TABLE) + + op.drop_column(USERS_TABLE, PROMO_GROUP_COLUMN) + + inspector = sa.inspect(bind) + if _table_exists(inspector, PROMO_GROUPS_TABLE): + op.drop_table(PROMO_GROUPS_TABLE) diff --git a/migrations/alembic/versions/3d9b35c6bd8f_add_paid_price_to_subscription.py b/migrations/alembic/versions/3d9b35c6bd8f_add_paid_price_to_subscription.py index 6b2e92f7..8d4898e9 100644 --- a/migrations/alembic/versions/3d9b35c6bd8f_add_paid_price_to_subscription.py +++ b/migrations/alembic/versions/3d9b35c6bd8f_add_paid_price_to_subscription.py @@ -11,6 +11,10 @@ from alembic import op import sqlalchemy as sa +def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool: + return any(col["name"] == column_name for col in inspector.get_columns(table_name)) + + # revision identifiers, used by Alembic. revision: str = '3d9b35c6bd8f' down_revision: Union[str, None] = None @@ -19,11 +23,23 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.add_column('subscriptions', sa.Column('paid_price_kopeks', sa.Integer(), nullable=False, server_default='0')) - # ### end Alembic commands ### + bind = op.get_bind() + inspector = sa.inspect(bind) + + if not _column_exists(inspector, "subscriptions", "paid_price_kopeks"): + op.add_column( + "subscriptions", + sa.Column( + "paid_price_kopeks", + sa.Integer(), + nullable=False, + server_default="0", + ), + ) def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_column('subscriptions', 'paid_price_kopeks') - # ### end Alembic commands ### + bind = op.get_bind() + inspector = sa.inspect(bind) + + if _column_exists(inspector, "subscriptions", "paid_price_kopeks"): + op.drop_column("subscriptions", "paid_price_kopeks") diff --git a/migrations/alembic/versions/5d1f1f8b2e9a_add_advertising_campaigns.py b/migrations/alembic/versions/5d1f1f8b2e9a_add_advertising_campaigns.py index 31a943b8..7f6d13e3 100644 --- a/migrations/alembic/versions/5d1f1f8b2e9a_add_advertising_campaigns.py +++ b/migrations/alembic/versions/5d1f1f8b2e9a_add_advertising_campaigns.py @@ -6,6 +6,21 @@ from alembic import op import sqlalchemy as sa +CAMPAIGNS_TABLE = "advertising_campaigns" +CAMPAIGNS_START_INDEX = "ix_advertising_campaigns_start_parameter" +CAMPAIGNS_ID_INDEX = "ix_advertising_campaigns_id" +REGISTRATIONS_TABLE = "advertising_campaign_registrations" +REGISTRATIONS_ID_INDEX = "ix_advertising_campaign_registrations_id" + + +def _table_exists(inspector: sa.Inspector, table_name: str) -> bool: + return table_name in inspector.get_table_names() + + +def _index_exists(inspector: sa.Inspector, table_name: str, index_name: str) -> bool: + return any(index["name"] == index_name for index in inspector.get_indexes(table_name)) + + revision: str = "5d1f1f8b2e9a" down_revision: Union[str, None] = "cbd1be472f3d" branch_labels: Union[str, Sequence[str], None] = None @@ -13,58 +28,119 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - op.create_table( - "advertising_campaigns", - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("name", sa.String(length=255), nullable=False), - sa.Column("start_parameter", sa.String(length=64), nullable=False), - sa.Column("bonus_type", sa.String(length=20), nullable=False), - sa.Column("balance_bonus_kopeks", sa.Integer(), nullable=False, server_default="0"), - sa.Column("subscription_duration_days", sa.Integer(), nullable=True), - sa.Column("subscription_traffic_gb", sa.Integer(), nullable=True), - sa.Column("subscription_device_limit", sa.Integer(), nullable=True), - sa.Column("subscription_squads", sa.JSON(), nullable=True), - sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")), - sa.Column("created_by", sa.Integer(), nullable=True), - sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), - sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), - sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"), - ) - op.create_index( - "ix_advertising_campaigns_start_parameter", - "advertising_campaigns", - ["start_parameter"], - unique=True, - ) - op.create_index( - "ix_advertising_campaigns_id", - "advertising_campaigns", - ["id"], - ) + bind = op.get_bind() + inspector = sa.inspect(bind) - op.create_table( - "advertising_campaign_registrations", - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("campaign_id", sa.Integer(), nullable=False), - sa.Column("user_id", sa.Integer(), nullable=False), - sa.Column("bonus_type", sa.String(length=20), nullable=False), - sa.Column("balance_bonus_kopeks", sa.Integer(), nullable=False, server_default="0"), - sa.Column("subscription_duration_days", sa.Integer(), nullable=True), - sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), - sa.ForeignKeyConstraint(["campaign_id"], ["advertising_campaigns.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), - sa.UniqueConstraint("campaign_id", "user_id", name="uq_campaign_user"), - ) - op.create_index( - "ix_advertising_campaign_registrations_id", - "advertising_campaign_registrations", - ["id"], - ) + if not _table_exists(inspector, CAMPAIGNS_TABLE): + op.create_table( + CAMPAIGNS_TABLE, + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("start_parameter", sa.String(length=64), nullable=False), + sa.Column("bonus_type", sa.String(length=20), nullable=False), + sa.Column( + "balance_bonus_kopeks", + sa.Integer(), + nullable=False, + server_default="0", + ), + sa.Column("subscription_duration_days", sa.Integer(), nullable=True), + sa.Column("subscription_traffic_gb", sa.Integer(), nullable=True), + sa.Column("subscription_device_limit", sa.Integer(), nullable=True), + sa.Column("subscription_squads", sa.JSON(), nullable=True), + sa.Column( + "is_active", + sa.Boolean(), + nullable=False, + server_default=sa.text("true"), + ), + sa.Column("created_by", sa.Integer(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(), + server_default=sa.func.now(), + nullable=False, + ), + sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"), + ) + inspector = sa.inspect(bind) + + if not _index_exists(inspector, CAMPAIGNS_TABLE, CAMPAIGNS_START_INDEX): + op.create_index( + CAMPAIGNS_START_INDEX, + CAMPAIGNS_TABLE, + ["start_parameter"], + unique=True, + ) + + inspector = sa.inspect(bind) + if not _index_exists(inspector, CAMPAIGNS_TABLE, CAMPAIGNS_ID_INDEX): + op.create_index(CAMPAIGNS_ID_INDEX, CAMPAIGNS_TABLE, ["id"]) + + inspector = sa.inspect(bind) + if not _table_exists(inspector, REGISTRATIONS_TABLE): + op.create_table( + REGISTRATIONS_TABLE, + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("campaign_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("bonus_type", sa.String(length=20), nullable=False), + sa.Column( + "balance_bonus_kopeks", + sa.Integer(), + nullable=False, + server_default="0", + ), + sa.Column("subscription_duration_days", sa.Integer(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(), + server_default=sa.func.now(), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["campaign_id"], + [f"{CAMPAIGNS_TABLE}.id"], + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.UniqueConstraint("campaign_id", "user_id", name="uq_campaign_user"), + ) + inspector = sa.inspect(bind) + + if not _index_exists(inspector, REGISTRATIONS_TABLE, REGISTRATIONS_ID_INDEX): + op.create_index( + REGISTRATIONS_ID_INDEX, + REGISTRATIONS_TABLE, + ["id"], + ) def downgrade() -> None: - op.drop_index("ix_advertising_campaign_registrations_id", table_name="advertising_campaign_registrations") - op.drop_table("advertising_campaign_registrations") - op.drop_index("ix_advertising_campaigns_id", table_name="advertising_campaigns") - op.drop_index("ix_advertising_campaigns_start_parameter", table_name="advertising_campaigns") - op.drop_table("advertising_campaigns") + bind = op.get_bind() + inspector = sa.inspect(bind) + + if _index_exists(inspector, REGISTRATIONS_TABLE, REGISTRATIONS_ID_INDEX): + op.drop_index(REGISTRATIONS_ID_INDEX, table_name=REGISTRATIONS_TABLE) + + inspector = sa.inspect(bind) + if _table_exists(inspector, REGISTRATIONS_TABLE): + op.drop_table(REGISTRATIONS_TABLE) + + inspector = sa.inspect(bind) + if _index_exists(inspector, CAMPAIGNS_TABLE, CAMPAIGNS_ID_INDEX): + op.drop_index(CAMPAIGNS_ID_INDEX, table_name=CAMPAIGNS_TABLE) + + inspector = sa.inspect(bind) + if _index_exists(inspector, CAMPAIGNS_TABLE, CAMPAIGNS_START_INDEX): + op.drop_index(CAMPAIGNS_START_INDEX, table_name=CAMPAIGNS_TABLE) + + inspector = sa.inspect(bind) + if _table_exists(inspector, CAMPAIGNS_TABLE): + op.drop_table(CAMPAIGNS_TABLE) diff --git a/migrations/alembic/versions/8fd1e338eb45_add_sent_notifications_table.py b/migrations/alembic/versions/8fd1e338eb45_add_sent_notifications_table.py index e7a0bff9..1010a39d 100644 --- a/migrations/alembic/versions/8fd1e338eb45_add_sent_notifications_table.py +++ b/migrations/alembic/versions/8fd1e338eb45_add_sent_notifications_table.py @@ -1,8 +1,11 @@ """add sent notifications table""" from typing import Sequence, Union + from alembic import op import sqlalchemy as sa +from sqlalchemy.engine.reflection import Inspector + revision: str = '8fd1e338eb45' down_revision: Union[str, None] = '3d9b35c6bd8f' @@ -10,18 +13,46 @@ branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None +TABLE_NAME = 'sent_notifications' +UNIQUE_CONSTRAINT_NAME = 'uq_sent_notifications' +UNIQUE_CONSTRAINT_COLUMNS = ['user_id', 'subscription_id', 'notification_type', 'days_before'] + + +def _table_exists(inspector: Inspector) -> bool: + return TABLE_NAME in inspector.get_table_names() + + +def _unique_constraint_exists(inspector: Inspector) -> bool: + existing_constraints = { + constraint['name'] for constraint in inspector.get_unique_constraints(TABLE_NAME) + } + return UNIQUE_CONSTRAINT_NAME in existing_constraints + + def upgrade() -> None: - op.create_table( - 'sent_notifications', - sa.Column('id', sa.Integer(), primary_key=True), - sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False), - sa.Column('subscription_id', sa.Integer(), sa.ForeignKey('subscriptions.id'), nullable=False), - sa.Column('notification_type', sa.String(length=50), nullable=False), - sa.Column('days_before', sa.Integer(), nullable=True), - sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()), - sa.UniqueConstraint('user_id', 'subscription_id', 'notification_type', 'days_before', name='uq_sent_notifications'), - ) + bind = op.get_bind() + inspector = sa.inspect(bind) + + if not _table_exists(inspector): + op.create_table( + TABLE_NAME, + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False), + sa.Column('subscription_id', sa.Integer(), sa.ForeignKey('subscriptions.id'), nullable=False), + sa.Column('notification_type', sa.String(length=50), nullable=False), + sa.Column('days_before', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()), + sa.UniqueConstraint(*UNIQUE_CONSTRAINT_COLUMNS, name=UNIQUE_CONSTRAINT_NAME), + ) + elif not _unique_constraint_exists(inspector): + op.create_unique_constraint( + UNIQUE_CONSTRAINT_NAME, TABLE_NAME, UNIQUE_CONSTRAINT_COLUMNS + ) def downgrade() -> None: - op.drop_table('sent_notifications') + bind = op.get_bind() + inspector = sa.inspect(bind) + + if _table_exists(inspector): + op.drop_table(TABLE_NAME) diff --git a/migrations/alembic/versions/cbd1be472f3d_add_cascade_to_sent_notifications.py b/migrations/alembic/versions/cbd1be472f3d_add_cascade_to_sent_notifications.py index 08ae5543..3abada9c 100644 --- a/migrations/alembic/versions/cbd1be472f3d_add_cascade_to_sent_notifications.py +++ b/migrations/alembic/versions/cbd1be472f3d_add_cascade_to_sent_notifications.py @@ -4,6 +4,21 @@ from typing import Sequence, Union from alembic import op import sqlalchemy as sa + +TABLE_NAME = "sent_notifications" +OLD_USER_FK = "sent_notifications_user_id_fkey" +OLD_SUBSCRIPTION_FK = "sent_notifications_subscription_id_fkey" +NEW_USER_FK = "fk_sent_notifications_user_id_users" +NEW_SUBSCRIPTION_FK = "fk_sent_notifications_subscription_id_subscriptions" + + +def _table_exists(inspector: sa.Inspector, table_name: str) -> bool: + return table_name in inspector.get_table_names() + + +def _foreign_key_exists(inspector: sa.Inspector, table_name: str, fk_name: str) -> bool: + return any(fk["name"] == fk_name for fk in inspector.get_foreign_keys(table_name)) + revision: str = 'cbd1be472f3d' down_revision: Union[str, None] = '8fd1e338eb45' branch_labels: Union[str, Sequence[str], None] = None @@ -11,14 +26,66 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - op.drop_constraint('sent_notifications_user_id_fkey', 'sent_notifications', type_='foreignkey') - op.drop_constraint('sent_notifications_subscription_id_fkey', 'sent_notifications', type_='foreignkey') - op.create_foreign_key('fk_sent_notifications_user_id_users', 'sent_notifications', 'users', ['user_id'], ['id'], ondelete='CASCADE') - op.create_foreign_key('fk_sent_notifications_subscription_id_subscriptions', 'sent_notifications', 'subscriptions', ['subscription_id'], ['id'], ondelete='CASCADE') + bind = op.get_bind() + inspector = sa.inspect(bind) + + if not _table_exists(inspector, TABLE_NAME): + return + + if _foreign_key_exists(inspector, TABLE_NAME, OLD_USER_FK): + op.drop_constraint(OLD_USER_FK, TABLE_NAME, type_="foreignkey") + + inspector = sa.inspect(bind) + if _foreign_key_exists(inspector, TABLE_NAME, OLD_SUBSCRIPTION_FK): + op.drop_constraint(OLD_SUBSCRIPTION_FK, TABLE_NAME, type_="foreignkey") + + inspector = sa.inspect(bind) + if not _foreign_key_exists(inspector, TABLE_NAME, NEW_USER_FK): + op.create_foreign_key( + NEW_USER_FK, + TABLE_NAME, + "users", + ["user_id"], + ["id"], + ondelete="CASCADE", + ) + + inspector = sa.inspect(bind) + if not _foreign_key_exists(inspector, TABLE_NAME, NEW_SUBSCRIPTION_FK): + op.create_foreign_key( + NEW_SUBSCRIPTION_FK, + TABLE_NAME, + "subscriptions", + ["subscription_id"], + ["id"], + ondelete="CASCADE", + ) def downgrade() -> None: - op.drop_constraint('fk_sent_notifications_user_id_users', 'sent_notifications', type_='foreignkey') - op.drop_constraint('fk_sent_notifications_subscription_id_subscriptions', 'sent_notifications', type_='foreignkey') - op.create_foreign_key('sent_notifications_user_id_fkey', 'sent_notifications', 'users', ['user_id'], ['id']) - op.create_foreign_key('sent_notifications_subscription_id_fkey', 'sent_notifications', 'subscriptions', ['subscription_id'], ['id']) + bind = op.get_bind() + inspector = sa.inspect(bind) + + if not _table_exists(inspector, TABLE_NAME): + return + + if _foreign_key_exists(inspector, TABLE_NAME, NEW_USER_FK): + op.drop_constraint(NEW_USER_FK, TABLE_NAME, type_="foreignkey") + + inspector = sa.inspect(bind) + if _foreign_key_exists(inspector, TABLE_NAME, NEW_SUBSCRIPTION_FK): + op.drop_constraint(NEW_SUBSCRIPTION_FK, TABLE_NAME, type_="foreignkey") + + inspector = sa.inspect(bind) + if not _foreign_key_exists(inspector, TABLE_NAME, OLD_USER_FK): + op.create_foreign_key(OLD_USER_FK, TABLE_NAME, "users", ["user_id"], ["id"]) + + inspector = sa.inspect(bind) + if not _foreign_key_exists(inspector, TABLE_NAME, OLD_SUBSCRIPTION_FK): + op.create_foreign_key( + OLD_SUBSCRIPTION_FK, + TABLE_NAME, + "subscriptions", + ["subscription_id"], + ["id"], + ) From 99bca0220b00493884cfa612b448d398654ad46a Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 20 Sep 2025 08:25:14 +0300 Subject: [PATCH 2/3] Delete migrations/alembic/versions/3d9b35c6bd8f_add_paid_price_to_subscription.py --- ...35c6bd8f_add_paid_price_to_subscription.py | 45 ------------------- 1 file changed, 45 deletions(-) delete mode 100644 migrations/alembic/versions/3d9b35c6bd8f_add_paid_price_to_subscription.py diff --git a/migrations/alembic/versions/3d9b35c6bd8f_add_paid_price_to_subscription.py b/migrations/alembic/versions/3d9b35c6bd8f_add_paid_price_to_subscription.py deleted file mode 100644 index 8d4898e9..00000000 --- a/migrations/alembic/versions/3d9b35c6bd8f_add_paid_price_to_subscription.py +++ /dev/null @@ -1,45 +0,0 @@ -"""add_paid_price_to_subscription - -Revision ID: 3d9b35c6bd8f -Revises: -Create Date: 2025-08-23 08:17:00.563340 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool: - return any(col["name"] == column_name for col in inspector.get_columns(table_name)) - - -# revision identifiers, used by Alembic. -revision: str = '3d9b35c6bd8f' -down_revision: Union[str, None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade(): - bind = op.get_bind() - inspector = sa.inspect(bind) - - if not _column_exists(inspector, "subscriptions", "paid_price_kopeks"): - op.add_column( - "subscriptions", - sa.Column( - "paid_price_kopeks", - sa.Integer(), - nullable=False, - server_default="0", - ), - ) - -def downgrade(): - bind = op.get_bind() - inspector = sa.inspect(bind) - - if _column_exists(inspector, "subscriptions", "paid_price_kopeks"): - op.drop_column("subscriptions", "paid_price_kopeks") From 466033d04c83161e725496164f6042486d029bb8 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 20 Sep 2025 08:25:29 +0300 Subject: [PATCH 3/3] Delete migrations/alembic/versions/cbd1be472f3d_add_cascade_to_sent_notifications.py --- ...72f3d_add_cascade_to_sent_notifications.py | 91 ------------------- 1 file changed, 91 deletions(-) delete mode 100644 migrations/alembic/versions/cbd1be472f3d_add_cascade_to_sent_notifications.py diff --git a/migrations/alembic/versions/cbd1be472f3d_add_cascade_to_sent_notifications.py b/migrations/alembic/versions/cbd1be472f3d_add_cascade_to_sent_notifications.py deleted file mode 100644 index 3abada9c..00000000 --- a/migrations/alembic/versions/cbd1be472f3d_add_cascade_to_sent_notifications.py +++ /dev/null @@ -1,91 +0,0 @@ -"""add cascade delete to sent notifications""" - -from typing import Sequence, Union -from alembic import op -import sqlalchemy as sa - - -TABLE_NAME = "sent_notifications" -OLD_USER_FK = "sent_notifications_user_id_fkey" -OLD_SUBSCRIPTION_FK = "sent_notifications_subscription_id_fkey" -NEW_USER_FK = "fk_sent_notifications_user_id_users" -NEW_SUBSCRIPTION_FK = "fk_sent_notifications_subscription_id_subscriptions" - - -def _table_exists(inspector: sa.Inspector, table_name: str) -> bool: - return table_name in inspector.get_table_names() - - -def _foreign_key_exists(inspector: sa.Inspector, table_name: str, fk_name: str) -> bool: - return any(fk["name"] == fk_name for fk in inspector.get_foreign_keys(table_name)) - -revision: str = 'cbd1be472f3d' -down_revision: Union[str, None] = '8fd1e338eb45' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - bind = op.get_bind() - inspector = sa.inspect(bind) - - if not _table_exists(inspector, TABLE_NAME): - return - - if _foreign_key_exists(inspector, TABLE_NAME, OLD_USER_FK): - op.drop_constraint(OLD_USER_FK, TABLE_NAME, type_="foreignkey") - - inspector = sa.inspect(bind) - if _foreign_key_exists(inspector, TABLE_NAME, OLD_SUBSCRIPTION_FK): - op.drop_constraint(OLD_SUBSCRIPTION_FK, TABLE_NAME, type_="foreignkey") - - inspector = sa.inspect(bind) - if not _foreign_key_exists(inspector, TABLE_NAME, NEW_USER_FK): - op.create_foreign_key( - NEW_USER_FK, - TABLE_NAME, - "users", - ["user_id"], - ["id"], - ondelete="CASCADE", - ) - - inspector = sa.inspect(bind) - if not _foreign_key_exists(inspector, TABLE_NAME, NEW_SUBSCRIPTION_FK): - op.create_foreign_key( - NEW_SUBSCRIPTION_FK, - TABLE_NAME, - "subscriptions", - ["subscription_id"], - ["id"], - ondelete="CASCADE", - ) - - -def downgrade() -> None: - bind = op.get_bind() - inspector = sa.inspect(bind) - - if not _table_exists(inspector, TABLE_NAME): - return - - if _foreign_key_exists(inspector, TABLE_NAME, NEW_USER_FK): - op.drop_constraint(NEW_USER_FK, TABLE_NAME, type_="foreignkey") - - inspector = sa.inspect(bind) - if _foreign_key_exists(inspector, TABLE_NAME, NEW_SUBSCRIPTION_FK): - op.drop_constraint(NEW_SUBSCRIPTION_FK, TABLE_NAME, type_="foreignkey") - - inspector = sa.inspect(bind) - if not _foreign_key_exists(inspector, TABLE_NAME, OLD_USER_FK): - op.create_foreign_key(OLD_USER_FK, TABLE_NAME, "users", ["user_id"], ["id"]) - - inspector = sa.inspect(bind) - if not _foreign_key_exists(inspector, TABLE_NAME, OLD_SUBSCRIPTION_FK): - op.create_foreign_key( - OLD_SUBSCRIPTION_FK, - TABLE_NAME, - "subscriptions", - ["subscription_id"], - ["id"], - )