diff --git a/alembic.ini b/alembic.ini deleted file mode 100644 index 025e0f5..0000000 --- a/alembic.ini +++ /dev/null @@ -1,119 +0,0 @@ -# A generic, single database configuration. - -[alembic] -# path to migration scripts -# Use forward slashes (/) also on windows to provide an os agnostic path -script_location = migration - -# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s -# Uncomment the line below if you want the files to be prepended with date and time -# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file -# for all available tokens -# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s - -# sys.path path, will be prepended to sys.path if present. -# defaults to the current working directory. -prepend_sys_path = . . - -# timezone to use when rendering the date within the migration file -# as well as the filename. -# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library. -# Any required deps can installed by adding `alembic[tz]` to the pip requirements -# string value is passed to ZoneInfo() -# leave blank for localtime -# timezone = - -# max length of characters to apply to the "slug" field -# truncate_slug_length = 40 - -# set to 'true' to run the environment during -# the 'revision' command, regardless of autogenerate -# revision_environment = false - -# set to 'true' to allow .pyc and .pyo files without -# a source .py file to be detected as revisions in the -# versions/ directory -# sourceless = false - -# version location specification; This defaults -# to src/migrations/versions. When using multiple version -# directories, initial revisions must be specified with --version-path. -# The path separator used here should be the separator specified by "version_path_separator" below. -# version_locations = %(here)s/bar:%(here)s/bat:src/migrations/versions - -# version path separator; As mentioned above, this is the character used to split -# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. -# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. -# Valid values for version_path_separator are: -# -# version_path_separator = : -# version_path_separator = ; -# version_path_separator = space -# version_path_separator = newline -# -# Use os.pathsep. Default configuration used for new projects. -version_path_separator = os - -# set to 'true' to search source files recursively -# in each "version_locations" directory -# new in Alembic version 1.10 -# recursive_version_locations = false - -# the output encoding used when revision files -# are written from script.py.mako -# output_encoding = utf-8 - -sqlalchemy.url = driver://user:pass@localhost/dbname - - -[post_write_hooks] -# post_write_hooks defines scripts or Python functions that are run -# on newly generated revision scripts. See the documentation for further -# detail and examples - -# format using "black" - use the console_scripts runner, against the "black" entrypoint -# hooks = black -# black.type = console_scripts -# black.entrypoint = black -# black.options = -l 79 REVISION_SCRIPT_FILENAME - -# lint with attempts to fix using "ruff" - use the exec runner, execute a binary -# hooks = ruff -# ruff.type = exec -# ruff.executable = %(here)s/.venv/bin/ruff -# ruff.options = --fix REVISION_SCRIPT_FILENAME - -# Logging configuration -[loggers] -keys = root,sqlalchemy,alembic - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = WARNING -handlers = console -qualname = - -[logger_sqlalchemy] -level = WARNING -handlers = -qualname = sqlalchemy.engine - -[logger_alembic] -level = INFO -handlers = -qualname = alembic - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = NOTSET -formatter = generic - -[formatter_generic] -format = %(levelname)-5.5s [%(name)s] %(message)s -datefmt = %H:%M:%S diff --git a/migration/env.py b/migration/env.py deleted file mode 100644 index e1206ff..0000000 --- a/migration/env.py +++ /dev/null @@ -1,49 +0,0 @@ -from logging.config import fileConfig - -from alembic import context -from sqlalchemy import engine_from_config, pool - -from src.config import settings -from src.domain import * # noqa - -try: - from src.domain import Base as SqlBase # type: ignore[attr-defined] -except ImportError: - SqlBase = None - -config = context.config - -if config.config_file_name: - fileConfig(config.config_file_name) - -config.set_main_option('sqlalchemy.url', f'{settings.db.URL}?async_fallback=True') -target_metadata = SqlBase.metadata if SqlBase else None - - -def run_migrations_offline() -> None: - context.configure( - url=config.get_main_option('sqlalchemy.url'), - target_metadata=target_metadata, - literal_binds=True, - dialect_opts={'paramstyle': 'named'}, - ) - with context.begin_transaction(): - context.run_migrations() - - -def run_migrations_online() -> None: - connectable = engine_from_config( - config.get_section(config.config_ini_section, {}), - prefix='sqlalchemy.', - poolclass=pool.NullPool, - ) - with connectable.connect() as connection: - context.configure(connection=connection, target_metadata=target_metadata) - with context.begin_transaction(): - context.run_migrations() - - -if context.is_offline_mode(): - run_migrations_offline() -else: - run_migrations_online() diff --git a/migration/script.py.mako b/migration/script.py.mako deleted file mode 100644 index fbc4b07..0000000 --- a/migration/script.py.mako +++ /dev/null @@ -1,26 +0,0 @@ -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision | comma,n} -Create Date: ${create_date} - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -${imports if imports else ""} - -# revision identifiers, used by Alembic. -revision: str = ${repr(up_revision)} -down_revision: Union[str, None] = ${repr(down_revision)} -branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} -depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} - - -def upgrade() -> None: - ${upgrades if upgrades else "pass"} - - -def downgrade() -> None: - ${downgrades if downgrades else "pass"} diff --git a/migration/versions/2025_12_07_add_placement_parser_support.py b/migration/versions/2025_12_07_add_placement_parser_support.py deleted file mode 100644 index 15dd153..0000000 --- a/migration/versions/2025_12_07_add_placement_parser_support.py +++ /dev/null @@ -1,43 +0,0 @@ -"""add_placement_parser_support - -Revision ID: c8f9a2d3e4b5 -Revises: b1d510ad3751 -Create Date: 2025-12-07 12:00:00.000000 - -""" - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = 'c8f9a2d3e4b5' -down_revision: str | None = 'b1d510ad3751' -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - # 1. Добавить поле external_channel_message_id - op.add_column('placement', sa.Column('external_channel_message_id', sa.BigInteger(), nullable=True)) - - # 2. Добавить новые значения в enum placementstatus - # В PostgreSQL нельзя добавлять значения в enum внутри транзакции, - # поэтому используем autocommit_block - with op.get_context().autocommit_block(): - op.execute("ALTER TYPE placementstatus ADD VALUE IF NOT EXISTS 'PENDING'") - op.execute("ALTER TYPE placementstatus ADD VALUE IF NOT EXISTS 'DELETED'") - - -def downgrade() -> None: - # 1. Откатить данные с новыми статусами на ACTIVE - op.execute("UPDATE placement SET status = 'ACTIVE' WHERE status IN ('PENDING', 'DELETED')") - - # 2. Удалить поле external_channel_message_id - op.drop_column('placement', 'external_channel_message_id') - - # ВАЖНО: В PostgreSQL нельзя удалить значения из enum. - # Новые значения 'PENDING' и 'DELETED' останутся в enum даже после downgrade. - # Это ограничение PostgreSQL. Если нужно полностью удалить значения, - # потребуется пересоздать enum, что сложнее и требует временного столбца. diff --git a/migration/versions/b1d510ad3751_.py b/migration/versions/b1d510ad3751_.py deleted file mode 100644 index 106fb6d..0000000 --- a/migration/versions/b1d510ad3751_.py +++ /dev/null @@ -1,65 +0,0 @@ -"""empty message - -Revision ID: b1d510ad3751 -Revises: f05f85d10837 -Create Date: 2025-12-01 13:05:17.215374 - -""" - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op -from sqlalchemy.dialects import postgresql - -subscription_status_enum = sa.Enum('ACTIVE', 'UNSUBSCRIBED', name='subscriptionstatus') - -# revision identifiers, used by Alembic. -revision: str = 'b1d510ad3751' -down_revision: str | None = 'f05f85d10837' -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_table( - 'telegram_state', - sa.Column('telegram_id', sa.BigInteger(), nullable=False), - sa.Column( - 'state', - sa.Enum( - 'CREATIVE_WAITING_CHANNEL', 'CREATIVE_WAITING_NAME', 'CREATIVE_WAITING_TEXT', name='telegramstateenum' - ), - nullable=False, - ), - sa.Column('context', postgresql.JSONB(astext_type=sa.Text()), nullable=False), - sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), - sa.PrimaryKeyConstraint('id'), - ) - op.create_index(op.f('ix_telegram_state_telegram_id'), 'telegram_state', ['telegram_id'], unique=True) - op.add_column('subscription', sa.Column('target_channel_id', sa.Uuid(), nullable=False)) - subscription_status_enum.create(op.get_bind(), checkfirst=True) - op.add_column('subscription', sa.Column('status', subscription_status_enum, nullable=False)) - op.add_column('subscription', sa.Column('unsubscribed_at', sa.DateTime(timezone=True), nullable=True)) - op.create_index(op.f('ix_subscription_target_channel_id'), 'subscription', ['target_channel_id'], unique=False) - fk_name = op.f('fk_subscription_target_channel_id') - op.create_foreign_key(fk_name, 'subscription', 'target_channel', ['target_channel_id'], ['id']) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - fk_name = op.f('fk_subscription_target_channel_id') - op.drop_constraint(fk_name, 'subscription', type_='foreignkey') - op.drop_index(op.f('ix_subscription_target_channel_id'), table_name='subscription') - op.drop_column('subscription', 'unsubscribed_at') - op.drop_column('subscription', 'status') - subscription_status_enum.drop(op.get_bind(), checkfirst=True) - op.drop_column('subscription', 'target_channel_id') - op.drop_index(op.f('ix_telegram_state_telegram_id'), table_name='telegram_state') - op.drop_table('telegram_state') - # ### end Alembic commands ### diff --git a/migration/versions/f05f85d10837_init.py b/migration/versions/f05f85d10837_init.py deleted file mode 100644 index 4f119f1..0000000 --- a/migration/versions/f05f85d10837_init.py +++ /dev/null @@ -1,262 +0,0 @@ -"""init - -Revision ID: f05f85d10837 -Revises: -Create Date: 2025-11-13 01:06:51.623272 - -""" - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = 'f05f85d10837' -down_revision: str | None = None -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_table( - 'subscriber', - sa.Column('telegram_id', sa.BigInteger(), nullable=False), - sa.Column('username', sa.String(), nullable=True), - sa.Column('first_name', sa.String(), nullable=True), - sa.Column('last_name', sa.String(), nullable=True), - sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), - sa.PrimaryKeyConstraint('id'), - ) - op.create_index(op.f('ix_subscriber_telegram_id'), 'subscriber', ['telegram_id'], unique=True) - op.create_table( - 'user', - sa.Column('telegram_id', sa.Integer(), nullable=False), - sa.Column('username', sa.String(), nullable=True), - sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), - sa.PrimaryKeyConstraint('id'), - ) - op.create_index(op.f('ix_user_telegram_id'), 'user', ['telegram_id'], unique=True) - op.create_table( - 'external_channel', - sa.Column('telegram_id', sa.BigInteger(), nullable=False), - sa.Column('title', sa.String(), nullable=False), - sa.Column('username', sa.String(), nullable=True), - sa.Column('description', sa.String(), nullable=True), - sa.Column('subscribers_count', sa.Integer(), nullable=True), - sa.Column('user_id', sa.Uuid(), nullable=False), - sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint( - ['user_id'], - ['user.id'], - ), - sa.PrimaryKeyConstraint('id'), - ) - op.create_index(op.f('ix_external_channel_telegram_id'), 'external_channel', ['telegram_id'], unique=True) - op.create_index(op.f('ix_external_channel_user_id'), 'external_channel', ['user_id'], unique=False) - op.create_index(op.f('ix_external_channel_username'), 'external_channel', ['username'], unique=False) - op.create_table( - 'login_token', - sa.Column('token', sa.String(), nullable=False), - sa.Column('user_id', sa.Uuid(), nullable=False), - sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('used_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint( - ['user_id'], - ['user.id'], - ), - sa.PrimaryKeyConstraint('id'), - ) - op.create_index(op.f('ix_login_token_token'), 'login_token', ['token'], unique=True) - op.create_table( - 'target_channel', - sa.Column('telegram_id', sa.BigInteger(), nullable=False), - sa.Column('title', sa.String(), nullable=False), - sa.Column('username', sa.String(), nullable=True), - sa.Column('status', sa.Enum('ACTIVE', 'INACTIVE', 'ARCHIVED', name='targetchannelstatus'), nullable=False), - sa.Column('user_id', sa.Uuid(), nullable=False), - sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint( - ['user_id'], - ['user.id'], - ), - sa.PrimaryKeyConstraint('id'), - ) - op.create_index(op.f('ix_target_channel_telegram_id'), 'target_channel', ['telegram_id'], unique=True) - op.create_index(op.f('ix_target_channel_user_id'), 'target_channel', ['user_id'], unique=False) - op.create_index(op.f('ix_target_channel_username'), 'target_channel', ['username'], unique=False) - op.create_table( - 'creative', - sa.Column('name', sa.String(), nullable=False), - sa.Column('text', sa.String(), nullable=False), - sa.Column('status', sa.Enum('ACTIVE', 'ARCHIVED', name='creativestatus'), nullable=False), - sa.Column('placements_count', sa.Integer(), server_default='0', nullable=False), - sa.Column('user_id', sa.Uuid(), nullable=False), - sa.Column('target_channel_id', sa.Uuid(), nullable=False), - sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint( - ['target_channel_id'], - ['target_channel.id'], - ), - sa.ForeignKeyConstraint( - ['user_id'], - ['user.id'], - ), - sa.PrimaryKeyConstraint('id'), - ) - op.create_index(op.f('ix_creative_target_channel_id'), 'creative', ['target_channel_id'], unique=False) - op.create_index(op.f('ix_creative_user_id'), 'creative', ['user_id'], unique=False) - op.create_table( - 'target_external_channel', - sa.Column('target_channel_id', sa.Uuid(), nullable=False), - sa.Column('external_channel_id', sa.Uuid(), nullable=False), - sa.ForeignKeyConstraint(['external_channel_id'], ['external_channel.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['target_channel_id'], ['target_channel.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('target_channel_id', 'external_channel_id'), - ) - op.create_table( - 'placement', - sa.Column('target_channel_id', sa.Uuid(), nullable=False), - sa.Column('external_channel_id', sa.Uuid(), nullable=False), - sa.Column('creative_id', sa.Uuid(), nullable=False), - sa.Column('user_id', sa.Uuid(), nullable=False), - sa.Column('placement_date', sa.DateTime(timezone=True), nullable=False), - sa.Column('cost', sa.Float(), nullable=True), - sa.Column('comment', sa.String(), nullable=True), - sa.Column('ad_post_url', sa.String(), nullable=True), - sa.Column('invite_link_type', sa.Enum('PUBLIC', 'APPROVAL', name='invitelinktype'), nullable=False), - sa.Column('invite_link', sa.String(), nullable=False), - sa.Column('status', sa.Enum('ACTIVE', 'ARCHIVED', name='placementstatus'), nullable=False), - sa.Column('subscriptions_count', sa.Integer(), server_default='0', nullable=False), - sa.Column('views_count', sa.Integer(), nullable=True), - sa.Column( - 'views_availability', - sa.Enum('UNKNOWN', 'AVAILABLE', 'UNAVAILABLE', 'MANUAL', name='postviewsavailability'), - nullable=False, - ), - sa.Column('last_views_fetch_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint( - ['creative_id'], - ['creative.id'], - ), - sa.ForeignKeyConstraint( - ['external_channel_id'], - ['external_channel.id'], - ), - sa.ForeignKeyConstraint( - ['target_channel_id'], - ['target_channel.id'], - ), - sa.ForeignKeyConstraint( - ['user_id'], - ['user.id'], - ), - sa.PrimaryKeyConstraint('id'), - ) - op.create_index(op.f('ix_placement_creative_id'), 'placement', ['creative_id'], unique=False) - op.create_index(op.f('ix_placement_external_channel_id'), 'placement', ['external_channel_id'], unique=False) - op.create_index(op.f('ix_placement_target_channel_id'), 'placement', ['target_channel_id'], unique=False) - op.create_index(op.f('ix_placement_user_id'), 'placement', ['user_id'], unique=False) - op.create_table( - 'placement_views_history', - sa.Column('placement_id', sa.Uuid(), nullable=False), - sa.Column('views_count', sa.Integer(), nullable=False), - sa.Column('fetched_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint( - ['placement_id'], - ['placement.id'], - ), - sa.PrimaryKeyConstraint('id'), - ) - op.create_index( - op.f('ix_placement_views_history_fetched_at'), 'placement_views_history', ['fetched_at'], unique=False - ) - op.create_index( - op.f('ix_placement_views_history_placement_id'), 'placement_views_history', ['placement_id'], unique=False - ) - op.create_table( - 'subscription', - sa.Column('placement_id', sa.Uuid(), nullable=False), - sa.Column('subscriber_id', sa.Uuid(), nullable=False), - sa.Column('invite_link', sa.String(), nullable=False), - sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint( - ['placement_id'], - ['placement.id'], - ), - sa.ForeignKeyConstraint( - ['subscriber_id'], - ['subscriber.id'], - ), - sa.PrimaryKeyConstraint('id'), - ) - op.create_index(op.f('ix_subscription_invite_link'), 'subscription', ['invite_link'], unique=False) - op.create_index(op.f('ix_subscription_placement_id'), 'subscription', ['placement_id'], unique=False) - op.create_index(op.f('ix_subscription_subscriber_id'), 'subscription', ['subscriber_id'], unique=False) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_subscription_subscriber_id'), table_name='subscription') - op.drop_index(op.f('ix_subscription_placement_id'), table_name='subscription') - op.drop_index(op.f('ix_subscription_invite_link'), table_name='subscription') - op.drop_table('subscription') - op.drop_index(op.f('ix_placement_views_history_placement_id'), table_name='placement_views_history') - op.drop_index(op.f('ix_placement_views_history_fetched_at'), table_name='placement_views_history') - op.drop_table('placement_views_history') - op.drop_index(op.f('ix_placement_user_id'), table_name='placement') - op.drop_index(op.f('ix_placement_target_channel_id'), table_name='placement') - op.drop_index(op.f('ix_placement_external_channel_id'), table_name='placement') - op.drop_index(op.f('ix_placement_creative_id'), table_name='placement') - op.drop_table('placement') - op.drop_table('target_external_channel') - op.drop_index(op.f('ix_creative_user_id'), table_name='creative') - op.drop_index(op.f('ix_creative_target_channel_id'), table_name='creative') - op.drop_table('creative') - op.drop_index(op.f('ix_target_channel_username'), table_name='target_channel') - op.drop_index(op.f('ix_target_channel_user_id'), table_name='target_channel') - op.drop_index(op.f('ix_target_channel_telegram_id'), table_name='target_channel') - op.drop_table('target_channel') - op.drop_index(op.f('ix_login_token_token'), table_name='login_token') - op.drop_table('login_token') - op.drop_index(op.f('ix_external_channel_username'), table_name='external_channel') - op.drop_index(op.f('ix_external_channel_user_id'), table_name='external_channel') - op.drop_index(op.f('ix_external_channel_telegram_id'), table_name='external_channel') - op.drop_table('external_channel') - op.drop_index(op.f('ix_user_telegram_id'), table_name='user') - op.drop_table('user') - op.drop_index(op.f('ix_subscriber_telegram_id'), table_name='subscriber') - op.drop_table('subscriber') - # ### end Alembic commands ### diff --git a/migrations/models/0_20251211144442_init.py b/migrations/models/0_20251211144442_init.py new file mode 100644 index 0000000..286bbc5 --- /dev/null +++ b/migrations/models/0_20251211144442_init.py @@ -0,0 +1,221 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + CREATE TABLE IF NOT EXISTS "subscriber" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "id" UUID NOT NULL PRIMARY KEY, + "telegram_id" BIGINT NOT NULL UNIQUE, + "username" VARCHAR(255), + "first_name" VARCHAR(255), + "last_name" VARCHAR(255) +); +CREATE INDEX IF NOT EXISTS "idx_subscriber_telegra_5eef32" ON "subscriber" ("telegram_id"); +CREATE TABLE IF NOT EXISTS "telegram_state" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "id" UUID NOT NULL PRIMARY KEY, + "telegram_id" BIGINT NOT NULL UNIQUE, + "state" VARCHAR(24) NOT NULL, + "context" JSONB NOT NULL +); +CREATE INDEX IF NOT EXISTS "idx_telegram_st_telegra_750503" ON "telegram_state" ("telegram_id"); +COMMENT ON COLUMN "telegram_state"."state" IS 'CREATIVE_WAITING_CHANNEL: creative_waiting_channel\nCREATIVE_WAITING_NAME: creative_waiting_name\nCREATIVE_WAITING_TEXT: creative_waiting_text'; +CREATE TABLE IF NOT EXISTS "user" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "id" UUID NOT NULL PRIMARY KEY, + "telegram_id" BIGINT NOT NULL UNIQUE, + "username" VARCHAR(255) +); +CREATE INDEX IF NOT EXISTS "idx_user_telegra_66ffbd" ON "user" ("telegram_id"); +CREATE TABLE IF NOT EXISTS "external_channel" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "id" UUID NOT NULL PRIMARY KEY, + "telegram_id" BIGINT NOT NULL UNIQUE, + "title" VARCHAR(255) NOT NULL, + "username" VARCHAR(255), + "description" TEXT, + "subscribers_count" INT, + "user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_external_ch_telegra_13cca5" ON "external_channel" ("telegram_id"); +CREATE INDEX IF NOT EXISTS "idx_external_ch_usernam_9b9fb4" ON "external_channel" ("username"); +CREATE INDEX IF NOT EXISTS "idx_external_ch_user_id_dd2b1c" ON "external_channel" ("user_id"); +CREATE TABLE IF NOT EXISTS "login_token" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "id" UUID NOT NULL PRIMARY KEY, + "token" VARCHAR(255) NOT NULL UNIQUE, + "expires_at" TIMESTAMPTZ NOT NULL, + "used_at" TIMESTAMPTZ, + "user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_login_token_token_2582d2" ON "login_token" ("token"); +CREATE TABLE IF NOT EXISTS "target_channel" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "id" UUID NOT NULL PRIMARY KEY, + "telegram_id" BIGINT NOT NULL UNIQUE, + "title" VARCHAR(255) NOT NULL, + "username" VARCHAR(255), + "status" VARCHAR(8) NOT NULL DEFAULT 'active', + "user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_target_chan_telegra_ab5a54" ON "target_channel" ("telegram_id"); +CREATE INDEX IF NOT EXISTS "idx_target_chan_usernam_a6cf8b" ON "target_channel" ("username"); +CREATE INDEX IF NOT EXISTS "idx_target_chan_user_id_c3bdc7" ON "target_channel" ("user_id"); +COMMENT ON COLUMN "target_channel"."status" IS 'ACTIVE: active\nINACTIVE: inactive\nARCHIVED: archived'; +CREATE TABLE IF NOT EXISTS "creative" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "id" UUID NOT NULL PRIMARY KEY, + "name" VARCHAR(255) NOT NULL, + "text" TEXT NOT NULL, + "status" VARCHAR(8) NOT NULL DEFAULT 'active', + "placements_count" INT NOT NULL DEFAULT 0, + "target_channel_id" UUID NOT NULL REFERENCES "target_channel" ("id") ON DELETE CASCADE, + "user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_creative_target__8e3b64" ON "creative" ("target_channel_id"); +CREATE INDEX IF NOT EXISTS "idx_creative_user_id_a27cfc" ON "creative" ("user_id"); +COMMENT ON COLUMN "creative"."status" IS 'ACTIVE: active\nARCHIVED: archived'; +CREATE TABLE IF NOT EXISTS "placement" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "id" UUID NOT NULL PRIMARY KEY, + "placement_date" TIMESTAMPTZ NOT NULL, + "cost" DOUBLE PRECISION, + "comment" TEXT, + "ad_post_url" VARCHAR(512), + "invite_link_type" VARCHAR(8) NOT NULL, + "invite_link" VARCHAR(512) NOT NULL, + "external_channel_message_id" INT, + "status" VARCHAR(8) NOT NULL DEFAULT 'active', + "subscriptions_count" INT NOT NULL DEFAULT 0, + "views_count" INT, + "views_availability" VARCHAR(11) NOT NULL DEFAULT 'unknown', + "last_views_fetch_at" TIMESTAMPTZ, + "creative_id" UUID NOT NULL REFERENCES "creative" ("id") ON DELETE CASCADE, + "external_channel_id" UUID NOT NULL REFERENCES "external_channel" ("id") ON DELETE CASCADE, + "target_channel_id" UUID NOT NULL REFERENCES "target_channel" ("id") ON DELETE CASCADE, + "user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_placement_creativ_32111d" ON "placement" ("creative_id"); +CREATE INDEX IF NOT EXISTS "idx_placement_externa_5b96c7" ON "placement" ("external_channel_id"); +CREATE INDEX IF NOT EXISTS "idx_placement_target__ed9070" ON "placement" ("target_channel_id"); +CREATE INDEX IF NOT EXISTS "idx_placement_user_id_7abaf9" ON "placement" ("user_id"); +COMMENT ON COLUMN "placement"."invite_link_type" IS 'PUBLIC: public\nAPPROVAL: approval'; +COMMENT ON COLUMN "placement"."status" IS 'PENDING: pending\nACTIVE: active\nDELETED: deleted\nARCHIVED: archived'; +COMMENT ON COLUMN "placement"."views_availability" IS 'UNKNOWN: unknown\nAVAILABLE: available\nUNAVAILABLE: unavailable\nMANUAL: manual'; +CREATE TABLE IF NOT EXISTS "placement_views_history" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "id" UUID NOT NULL PRIMARY KEY, + "views_count" INT NOT NULL, + "fetched_at" TIMESTAMPTZ NOT NULL, + "placement_id" UUID NOT NULL REFERENCES "placement" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_placement_v_fetched_4c8f51" ON "placement_views_history" ("fetched_at"); +CREATE INDEX IF NOT EXISTS "idx_placement_v_placeme_89e0f4" ON "placement_views_history" ("placement_id"); +CREATE TABLE IF NOT EXISTS "subscription" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "id" UUID NOT NULL PRIMARY KEY, + "invite_link" VARCHAR(512) NOT NULL, + "status" VARCHAR(12) NOT NULL DEFAULT 'active', + "unsubscribed_at" TIMESTAMPTZ, + "placement_id" UUID NOT NULL REFERENCES "placement" ("id") ON DELETE CASCADE, + "subscriber_id" UUID NOT NULL REFERENCES "subscriber" ("id") ON DELETE CASCADE, + "target_channel_id" UUID NOT NULL REFERENCES "target_channel" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_subscriptio_invite__f2058d" ON "subscription" ("invite_link"); +CREATE INDEX IF NOT EXISTS "idx_subscriptio_placeme_5decb2" ON "subscription" ("placement_id"); +CREATE INDEX IF NOT EXISTS "idx_subscriptio_subscri_e75729" ON "subscription" ("subscriber_id"); +CREATE INDEX IF NOT EXISTS "idx_subscriptio_target__79117e" ON "subscription" ("target_channel_id"); +COMMENT ON COLUMN "subscription"."status" IS 'ACTIVE: active\nUNSUBSCRIBED: unsubscribed'; +CREATE TABLE IF NOT EXISTS "aerich" ( + "id" SERIAL NOT NULL PRIMARY KEY, + "version" VARCHAR(255) NOT NULL, + "app" VARCHAR(100) NOT NULL, + "content" JSONB NOT NULL +); +CREATE TABLE IF NOT EXISTS "target_external_channel" ( + "target_channel_id" UUID NOT NULL REFERENCES "target_channel" ("id") ON DELETE CASCADE, + "external_channel_id" UUID NOT NULL REFERENCES "external_channel" ("id") ON DELETE CASCADE +); +CREATE UNIQUE INDEX IF NOT EXISTS "uidx_target_exte_target__ba70ec" ON "target_external_channel" ("target_channel_id", "external_channel_id");""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + """ + + +MODELS_STATE = ( + "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" +) diff --git a/pyproject.toml b/pyproject.toml index 6f17220..3f6e781 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.13" dependencies = [ + "aerich>=0.9.2", "aiogram>=3.16.0", "aiolimiter>=1.2.1", "asyncpg>=0.30.0", @@ -66,3 +67,8 @@ exclude = [ [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["src", "."] + +[tool.aerich] +tortoise_orm = "shared.datebase_base.TORTOISE_ORM" +location = "./migrations" +src_folder = "./." diff --git a/shared/datebase_base.py b/shared/datebase_base.py index 8e84347..3f7c998 100644 --- a/shared/datebase_base.py +++ b/shared/datebase_base.py @@ -1,3 +1,5 @@ +import os + import pydantic from tortoise import Tortoise @@ -10,6 +12,19 @@ class DatabaseConfig(pydantic.BaseModel): MAX_OVERFLOW: int = 10 +TORTOISE_ORM = { + 'connections': {'default': os.getenv('DB__URL')}, + 'apps': { + 'models': { + 'models': ['src.domain', 'aerich.models'], + 'default_connection': 'default', + }, + }, + 'use_tz': True, + 'timezone': 'UTC', +} + + class DatabaseBase: def __init__(self, config: DatabaseConfig) -> None: self.config = config @@ -17,7 +32,7 @@ class DatabaseBase: async def connect(self) -> None: await Tortoise.init( db_url=str(self.config.URL), - modules={'models': ['src.domain']}, + modules={'models': ['src.domain', 'aerich.models']}, use_tz=True, timezone='UTC', ) @@ -47,35 +62,19 @@ class DatabaseBase: conn = connections.get('default') - # Проверяем существование таблицы alembic_version + # Проверяем существование таблицы aerich result = await conn.execute_query( - "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'alembic_version')" + "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'aerich')" ) if not result[1][0]['exists']: - logging.error('Migrations not applied. Run: alembic upgrade head') + logging.warning('Migrations not applied. Run: aerich upgrade') return - # Получаем текущую версию - result = await conn.execute_query('SELECT version_num FROM alembic_version') - if not result[1]: - logging.error('No migration version found in database. Run: alembic upgrade head') - return - - current_version = result[1][0]['version_num'] - - # Сравниваем с head версией + # Получаем последнюю примененную версию try: - from alembic.config import Config - from alembic.script import ScriptDirectory - - alembic_cfg = Config('alembic.ini') - script = ScriptDirectory.from_config(alembic_cfg) - head_revision = script.get_current_head() - - if head_revision and current_version != head_revision: - logging.warning( - f'Database version {current_version} is outdated (latest: {head_revision}). ' - f'Run: alembic upgrade head' - ) + result = await conn.execute_query('SELECT version, app FROM aerich ORDER BY id DESC LIMIT 1') + if result[1]: + current_version = result[1][0]['version'] + logging.info(f'Current migration version: {current_version}') except Exception as e: - logging.debug(f'Could not check migration head: {e}') + logging.debug(f'Could not check migration version: {e}') diff --git a/src/domain/external_channel.py b/src/domain/external_channel.py index 0448eb2..7e68b94 100644 --- a/src/domain/external_channel.py +++ b/src/domain/external_channel.py @@ -22,9 +22,8 @@ class ExternalChannel(TimestampedModel): 'models.User', related_name='external_channels', on_delete=fields.CASCADE, index=True ) - target_channels: fields.ManyToManyRelation['TargetChannel'] - if TYPE_CHECKING: + target_channels: fields.ManyToManyRelation['TargetChannel'] user_id: UUID class Meta: diff --git a/src/domain/target_channel.py b/src/domain/target_channel.py index db0ca80..527aa23 100644 --- a/src/domain/target_channel.py +++ b/src/domain/target_channel.py @@ -32,6 +32,8 @@ class TargetChannel(TimestampedModel): 'models.ExternalChannel', related_name='target_channels', through='target_external_channel', + forward_key='external_channel_id', + backward_key='target_channel_id', ) if TYPE_CHECKING: diff --git a/uv.lock b/uv.lock index e4d3e95..0f3919d 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,21 @@ version = 1 revision = 3 requires-python = ">=3.13" +[[package]] +name = "aerich" +version = "0.9.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "asyncclick" }, + { name = "dictdiffer" }, + { name = "tortoise-orm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/60/5d3885f531fab2cecec67510e7b821efc403940ed9eefd034b2c21350f3c/aerich-0.9.2.tar.gz", hash = "sha256:02d58658714eebe396fe7bd9f9401db3a60a44dc885910ad3990920d0357317d", size = 74231, upload-time = "2025-10-10T05:53:49.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/1a/956c6b1e35881bb9835a33c8db1565edcd133f8e45321010489092a0df40/aerich-0.9.2-py3-none-any.whl", hash = "sha256:d0f007acb21f6559f1eccd4e404fb039cf48af2689e0669afa62989389c0582d", size = 46451, upload-time = "2025-10-10T05:53:48.71Z" }, +] + [[package]] name = "aiofiles" version = "24.1.0" @@ -149,6 +164,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, ] +[[package]] +name = "asyncclick" +version = "8.3.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/ca/25e426d16bd0e91c1c9259112cecd17b2c2c239bdd8e5dba430f3bd5e3ef/asyncclick-8.3.0.7.tar.gz", hash = "sha256:8a80d8ac613098ee6a9a8f0248f60c66c273e22402cf3f115ed7f071acfc71d3", size = 277634, upload-time = "2025-10-11T08:35:44.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/d9/782ffcb4c97b889bc12d8276637d2739b99520390ee8fec77c07416c5d12/asyncclick-8.3.0.7-py3-none-any.whl", hash = "sha256:7607046de39a3f315867cad818849f973e29d350c10d92f251db3ff7600c6c7d", size = 109925, upload-time = "2025-10-11T08:35:43.378Z" }, +] + [[package]] name = "asyncpg" version = "0.30.0" @@ -217,6 +244,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "dictdiffer" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/7b/35cbccb7effc5d7e40f4c55e2b79399e1853041997fcda15c9ff160abba0/dictdiffer-0.9.0.tar.gz", hash = "sha256:17bacf5fbfe613ccf1b6d512bd766e6b21fb798822a133aa86098b8ac9997578", size = 31513, upload-time = "2021-07-22T13:24:29.276Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/ef/4cb333825d10317a36a1154341ba37e6e9c087bac99c1990ef07ffdb376f/dictdiffer-0.9.0-py2.py3-none-any.whl", hash = "sha256:442bfc693cfcadaf46674575d2eba1c53b42f5e404218ca2c2ff549f2df56595", size = 16754, upload-time = "2021-07-22T13:24:26.783Z" }, +] + [[package]] name = "fastapi" version = "0.121.0" @@ -970,6 +1006,7 @@ name = "tgex-backend" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "aerich" }, { name = "aiogram" }, { name = "aiolimiter" }, { name = "asyncpg" }, @@ -999,6 +1036,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "aerich", specifier = ">=0.9.2" }, { name = "aiogram", specifier = ">=3.16.0" }, { name = "aiolimiter", specifier = ">=1.2.1" }, { name = "asyncpg", specifier = ">=0.30.0" },