diff --git a/migration/versions/6517eda4a92e_add_purchases_table.py b/migration/versions/6517eda4a92e_add_purchases_table.py deleted file mode 100644 index 2342f5e..0000000 --- a/migration/versions/6517eda4a92e_add_purchases_table.py +++ /dev/null @@ -1,74 +0,0 @@ -"""add purchases table - -Revision ID: 6517eda4a92e -Revises: e005b383b5c6 -Create Date: 2025-11-10 16:36:15.533520 - -""" - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = '6517eda4a92e' -down_revision: str | None = 'e005b383b5c6' -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( - 'purchases', - 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='purchasestatus'), nullable=False), - sa.Column('subscriptions_count', sa.Integer(), server_default='0', nullable=False), - sa.Column('views_count', sa.Integer(), 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'], - ['creatives.id'], - ), - sa.ForeignKeyConstraint( - ['external_channel_id'], - ['externalchannels.id'], - ), - sa.ForeignKeyConstraint( - ['target_channel_id'], - ['targetchannels.id'], - ), - sa.ForeignKeyConstraint( - ['user_id'], - ['users.id'], - ), - sa.PrimaryKeyConstraint('id'), - ) - op.create_index(op.f('ix_purchases_creative_id'), 'purchases', ['creative_id'], unique=False) - op.create_index(op.f('ix_purchases_external_channel_id'), 'purchases', ['external_channel_id'], unique=False) - op.create_index(op.f('ix_purchases_target_channel_id'), 'purchases', ['target_channel_id'], unique=False) - op.create_index(op.f('ix_purchases_user_id'), 'purchases', ['user_id'], unique=False) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_purchases_user_id'), table_name='purchases') - op.drop_index(op.f('ix_purchases_target_channel_id'), table_name='purchases') - op.drop_index(op.f('ix_purchases_external_channel_id'), table_name='purchases') - op.drop_index(op.f('ix_purchases_creative_id'), table_name='purchases') - op.drop_table('purchases') - # ### end Alembic commands ### diff --git a/migration/versions/__init__.py b/migration/versions/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/migration/versions/e005b383b5c6_init.py b/migration/versions/e005b383b5c6_init.py deleted file mode 100644 index 5cb8b23..0000000 --- a/migration/versions/e005b383b5c6_init.py +++ /dev/null @@ -1,145 +0,0 @@ -"""init - -Revision ID: e005b383b5c6 -Revises: -Create Date: 2025-11-10 15:11:29.376116 - -""" - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = 'e005b383b5c6' -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( - 'users', - 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_users_telegram_id'), 'users', ['telegram_id'], unique=True) - op.create_table( - 'externalchannels', - 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'], - ['users.id'], - ), - sa.PrimaryKeyConstraint('id'), - ) - op.create_index(op.f('ix_externalchannels_telegram_id'), 'externalchannels', ['telegram_id'], unique=True) - op.create_index(op.f('ix_externalchannels_user_id'), 'externalchannels', ['user_id'], unique=False) - op.create_index(op.f('ix_externalchannels_username'), 'externalchannels', ['username'], unique=False) - op.create_table( - 'logintokens', - 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'], - ['users.id'], - ), - sa.PrimaryKeyConstraint('id'), - ) - op.create_index(op.f('ix_logintokens_token'), 'logintokens', ['token'], unique=True) - op.create_table( - 'targetchannels', - 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'], - ['users.id'], - ), - sa.PrimaryKeyConstraint('id'), - ) - op.create_index(op.f('ix_targetchannels_telegram_id'), 'targetchannels', ['telegram_id'], unique=True) - op.create_index(op.f('ix_targetchannels_user_id'), 'targetchannels', ['user_id'], unique=False) - op.create_index(op.f('ix_targetchannels_username'), 'targetchannels', ['username'], unique=False) - op.create_table( - 'creatives', - 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('purchases_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'], - ['targetchannels.id'], - ), - sa.ForeignKeyConstraint( - ['user_id'], - ['users.id'], - ), - sa.PrimaryKeyConstraint('id'), - ) - op.create_index(op.f('ix_creatives_target_channel_id'), 'creatives', ['target_channel_id'], unique=False) - op.create_index(op.f('ix_creatives_user_id'), 'creatives', ['user_id'], unique=False) - op.create_table( - 'target_channel_external_channels', - sa.Column('target_channel_id', sa.Uuid(), nullable=False), - sa.Column('external_channel_id', sa.Uuid(), nullable=False), - sa.ForeignKeyConstraint(['external_channel_id'], ['externalchannels.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['target_channel_id'], ['targetchannels.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('target_channel_id', 'external_channel_id'), - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('target_channel_external_channels') - op.drop_index(op.f('ix_creatives_user_id'), table_name='creatives') - op.drop_index(op.f('ix_creatives_target_channel_id'), table_name='creatives') - op.drop_table('creatives') - op.drop_index(op.f('ix_targetchannels_username'), table_name='targetchannels') - op.drop_index(op.f('ix_targetchannels_user_id'), table_name='targetchannels') - op.drop_index(op.f('ix_targetchannels_telegram_id'), table_name='targetchannels') - op.drop_table('targetchannels') - op.drop_index(op.f('ix_logintokens_token'), table_name='logintokens') - op.drop_table('logintokens') - op.drop_index(op.f('ix_externalchannels_username'), table_name='externalchannels') - op.drop_index(op.f('ix_externalchannels_user_id'), table_name='externalchannels') - op.drop_index(op.f('ix_externalchannels_telegram_id'), table_name='externalchannels') - op.drop_table('externalchannels') - op.drop_index(op.f('ix_users_telegram_id'), table_name='users') - op.drop_table('users') - # ### end Alembic commands ### diff --git a/migration/versions/e7ab0b38a8bf_add_subscriptions_table.py b/migration/versions/e7ab0b38a8bf_add_subscriptions_table.py deleted file mode 100644 index 7d0f5fc..0000000 --- a/migration/versions/e7ab0b38a8bf_add_subscriptions_table.py +++ /dev/null @@ -1,51 +0,0 @@ -"""add subscriptions table - -Revision ID: e7ab0b38a8bf -Revises: 6517eda4a92e -Create Date: 2025-11-10 16:48:25.310928 - -""" - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = 'e7ab0b38a8bf' -down_revision: str | None = '6517eda4a92e' -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( - 'subscriptions', - sa.Column('purchase_id', sa.Uuid(), nullable=False), - sa.Column('user_telegram_id', sa.BigInteger(), nullable=False), - sa.Column('username', sa.String(), nullable=True), - 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( - ['purchase_id'], - ['purchases.id'], - ), - sa.PrimaryKeyConstraint('id'), - ) - op.create_index(op.f('ix_subscriptions_invite_link'), 'subscriptions', ['invite_link'], unique=False) - op.create_index(op.f('ix_subscriptions_purchase_id'), 'subscriptions', ['purchase_id'], unique=False) - op.create_index(op.f('ix_subscriptions_user_telegram_id'), 'subscriptions', ['user_telegram_id'], unique=False) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_subscriptions_user_telegram_id'), table_name='subscriptions') - op.drop_index(op.f('ix_subscriptions_purchase_id'), table_name='subscriptions') - op.drop_index(op.f('ix_subscriptions_invite_link'), table_name='subscriptions') - op.drop_table('subscriptions') - # ### end Alembic commands ### diff --git a/migration/versions/f05f85d10837_init.py b/migration/versions/f05f85d10837_init.py new file mode 100644 index 0000000..a47ff33 --- /dev/null +++ b/migration/versions/f05f85d10837_init.py @@ -0,0 +1,208 @@ +"""init + +Revision ID: f05f85d10837 +Revises: +Create Date: 2025-11-13 01:06:51.623272 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'f05f85d10837' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[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/migration/versions/fafe143ee0f4_add_views_tracking.py b/migration/versions/fafe143ee0f4_add_views_tracking.py deleted file mode 100644 index 43723a1..0000000 --- a/migration/versions/fafe143ee0f4_add_views_tracking.py +++ /dev/null @@ -1,67 +0,0 @@ -"""add views tracking - -Revision ID: fafe143ee0f4 -Revises: e7ab0b38a8bf -Create Date: 2025-11-10 16:57:28.652101 - -""" - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = 'fafe143ee0f4' -down_revision: str | None = 'e7ab0b38a8bf' -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - - # Создаём enum тип для статуса доступности просмотров ПЕРЕД использованием - op.execute("CREATE TYPE postviewsavailability AS ENUM ('UNKNOWN', 'AVAILABLE', 'UNAVAILABLE', 'MANUAL')") - - op.create_table( - 'viewssnapshots', - sa.Column('purchase_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( - ['purchase_id'], - ['purchases.id'], - ), - sa.PrimaryKeyConstraint('id'), - ) - op.create_index(op.f('ix_viewssnapshots_fetched_at'), 'viewssnapshots', ['fetched_at'], unique=False) - op.create_index(op.f('ix_viewssnapshots_purchase_id'), 'viewssnapshots', ['purchase_id'], unique=False) - op.add_column( - 'purchases', - sa.Column( - 'views_availability', - sa.Enum('UNKNOWN', 'AVAILABLE', 'UNAVAILABLE', 'MANUAL', name='postviewsavailability'), - nullable=False, - server_default='UNKNOWN', - ), - ) - op.add_column('purchases', sa.Column('last_views_fetch_at', sa.DateTime(timezone=True), nullable=True)) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_column('purchases', 'last_views_fetch_at') - op.drop_column('purchases', 'views_availability') - op.drop_index(op.f('ix_viewssnapshots_purchase_id'), table_name='viewssnapshots') - op.drop_index(op.f('ix_viewssnapshots_fetched_at'), table_name='viewssnapshots') - op.drop_table('viewssnapshots') - - # Удаляём enum тип - op.execute('DROP TYPE IF EXISTS postviewsavailability') - # ### end Alembic commands ### diff --git a/src/adapter/postgres.py b/src/adapter/postgres.py index 3d2b6f9..7044766 100644 --- a/src/adapter/postgres.py +++ b/src/adapter/postgres.py @@ -14,8 +14,8 @@ class Postgres(DatabaseBase): return await self.session.get(domain.User, user_id) elif telegram_id: - stmt = select(domain.User).where(domain.User.telegram_id == telegram_id) - result = await self.session.execute(stmt) + q = select(domain.User).where(domain.User.telegram_id == telegram_id) + result = await self.session.execute(q) return result.scalar_one_or_none() raise ValueError('Either user_id or telegram_id must be provided') @@ -37,15 +37,15 @@ class Postgres(DatabaseBase): return login_token async def get_login_token(self, token: str) -> domain.LoginToken | None: - stmt = select(domain.LoginToken).where(domain.LoginToken.token == token) + q = select(domain.LoginToken).where(domain.LoginToken.token == token) - result = await self.session.execute(stmt) + result = await self.session.execute(q) return result.scalar_one_or_none() async def mark_token_as_used(self, token: str) -> None: - stmt = select(domain.LoginToken).where(domain.LoginToken.token == token) + q = select(domain.LoginToken).where(domain.LoginToken.token == token) - result = await self.session.execute(stmt) + result = await self.session.execute(q) login_token = result.scalar_one_or_none() if login_token: login_token.used_at = datetime.datetime.now(datetime.UTC) @@ -57,13 +57,13 @@ class Postgres(DatabaseBase): if not channel_id and not telegram_id: raise ValueError('Either channel_id or telegram_id must be provided') - stmt = select(domain.TargetChannel).where(domain.TargetChannel.user_id == user_id) + q = select(domain.TargetChannel).where(domain.TargetChannel.user_id == user_id) if channel_id: - stmt = stmt.where(domain.TargetChannel.id == channel_id) + q = q.where(domain.TargetChannel.id == channel_id) if telegram_id: - stmt = stmt.where(domain.TargetChannel.telegram_id == telegram_id) + q = q.where(domain.TargetChannel.telegram_id == telegram_id) - result = await self.session.execute(stmt) + result = await self.session.execute(q) return result.scalar_one_or_none() async def create_target_channel(self, channel: domain.TargetChannel) -> domain.TargetChannel: @@ -93,11 +93,11 @@ class Postgres(DatabaseBase): return existing async def get_user_target_channels(self, user_id: uuid.UUID) -> list[domain.TargetChannel]: - stmt = select(domain.TargetChannel).where( + q = select(domain.TargetChannel).where( domain.TargetChannel.status == domain.TargetChannelStatus.ACTIVE, domain.TargetChannel.user_id == user_id, ) - result = await self.session.execute(stmt) + result = await self.session.execute(q) return list(result.scalars().all()) async def update_target_channel(self, channel: domain.TargetChannel) -> domain.TargetChannel: @@ -108,13 +108,13 @@ class Postgres(DatabaseBase): async def get_external_channel( self, user_id: uuid.UUID, channel_id: uuid.UUID | None = None, telegram_id: int | None = None ) -> domain.ExternalChannel | None: - stmt = select(domain.ExternalChannel).where(domain.ExternalChannel.user_id == user_id) + q = select(domain.ExternalChannel).where(domain.ExternalChannel.user_id == user_id) if channel_id: - stmt = stmt.where(domain.ExternalChannel.id == channel_id) + q = q.where(domain.ExternalChannel.id == channel_id) if telegram_id: - stmt = stmt.where(domain.ExternalChannel.telegram_id == telegram_id) + q = q.where(domain.ExternalChannel.telegram_id == telegram_id) - result = await self.session.execute(stmt) + result = await self.session.execute(q) return result.scalar_one_or_none() async def create_external_channel(self, channel: domain.ExternalChannel) -> domain.ExternalChannel: @@ -124,23 +124,23 @@ class Postgres(DatabaseBase): return channel async def get_external_channels_for_target(self, target_channel_id: uuid.UUID) -> list[domain.ExternalChannel]: - stmt = ( + q = ( select(domain.ExternalChannel) .join(domain.ExternalChannel.target_channels) .where(domain.TargetChannel.id == target_channel_id) ) - result = await self.session.execute(stmt) + result = await self.session.execute(q) return list(result.scalars().all()) async def add_external_channel_to_targets( self, external_channel_id: uuid.UUID, target_channel_ids: list[uuid.UUID] ) -> None: - stmt = ( + q = ( select(domain.ExternalChannel) .where(domain.ExternalChannel.id == external_channel_id) .options(selectinload(domain.ExternalChannel.target_channels)) ) - result = await self.session.execute(stmt) + result = await self.session.execute(q) external_channel = result.scalar_one_or_none() if not external_channel: @@ -156,12 +156,12 @@ class Postgres(DatabaseBase): async def remove_external_channel_from_target( self, external_channel_id: uuid.UUID, target_channel_id: uuid.UUID ) -> None: - stmt = ( + q = ( select(domain.ExternalChannel) .where(domain.ExternalChannel.id == external_channel_id) .options(selectinload(domain.ExternalChannel.target_channels)) ) - result = await self.session.execute(stmt) + result = await self.session.execute(q) external_channel = result.scalar_one_or_none() if not external_channel: @@ -174,8 +174,8 @@ class Postgres(DatabaseBase): await self.session.flush() async def delete_external_channel(self, channel_id: uuid.UUID) -> None: - stmt = delete(domain.ExternalChannel).where(domain.ExternalChannel.id == channel_id) - await self.session.execute(stmt) + q = delete(domain.ExternalChannel).where(domain.ExternalChannel.id == channel_id) + await self.session.execute(q) await self.session.flush() async def update_external_channel(self, channel: domain.ExternalChannel) -> domain.ExternalChannel: @@ -184,12 +184,12 @@ class Postgres(DatabaseBase): return channel async def get_creative(self, user_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Creative | None: - stmt = ( + q = ( select(domain.Creative) .options(selectinload(domain.Creative.target_channel)) .where(domain.Creative.id == creative_id, domain.Creative.user_id == user_id) ) - result = await self.session.execute(stmt) + result = await self.session.execute(q) return result.scalar_one_or_none() async def create_creative(self, creative: domain.Creative) -> domain.Creative: @@ -206,59 +206,59 @@ class Postgres(DatabaseBase): async def get_user_creatives( self, user_id: uuid.UUID, *, target_channel_id: uuid.UUID | None = None, include_archived: bool = False ) -> list[domain.Creative]: - stmt = ( + q = ( select(domain.Creative) .options(selectinload(domain.Creative.target_channel)) .where(domain.Creative.user_id == user_id) ) if target_channel_id: - stmt = stmt.where(domain.Creative.target_channel_id == target_channel_id) + q = q.where(domain.Creative.target_channel_id == target_channel_id) if not include_archived: - stmt = stmt.where(domain.Creative.status == domain.CreativeStatus.ACTIVE) + q = q.where(domain.Creative.status == domain.CreativeStatus.ACTIVE) - stmt = stmt.order_by(domain.Creative.created_at.desc()) + q = q.order_by(domain.Creative.created_at.desc()) - result = await self.session.execute(stmt) + result = await self.session.execute(q) return list(result.scalars().all()) async def delete_creative(self, creative_id: uuid.UUID) -> None: - stmt = delete(domain.Creative).where(domain.Creative.id == creative_id) - await self.session.execute(stmt) + q = delete(domain.Creative).where(domain.Creative.id == creative_id) + await self.session.execute(q) await self.session.flush() - async def get_purchase(self, user_id: uuid.UUID, purchase_id: uuid.UUID) -> domain.Purchase | None: - stmt = ( - select(domain.Purchase) + async def get_placement(self, user_id: uuid.UUID, placement_id: uuid.UUID) -> domain.Placement | None: + q = ( + select(domain.Placement) .options( - selectinload(domain.Purchase.target_channel), - selectinload(domain.Purchase.external_channel), - selectinload(domain.Purchase.creative), + selectinload(domain.Placement.target_channel), + selectinload(domain.Placement.external_channel), + selectinload(domain.Placement.creative), ) - .where(domain.Purchase.id == purchase_id, domain.Purchase.user_id == user_id) + .where(domain.Placement.id == placement_id, domain.Placement.user_id == user_id) ) - result = await self.session.execute(stmt) + result = await self.session.execute(q) return result.scalar_one_or_none() - async def create_purchase(self, purchase: domain.Purchase) -> domain.Purchase: - self.session.add(purchase) + async def create_placement(self, placement: domain.Placement) -> domain.Placement: + self.session.add(placement) await self.session.flush() await self.session.refresh( - purchase, + placement, ['target_channel', 'external_channel', 'creative'], ) - return purchase + return placement - async def update_purchase(self, purchase: domain.Purchase) -> domain.Purchase: + async def update_placement(self, placement: domain.Placement) -> domain.Placement: await self.session.flush() await self.session.refresh( - purchase, + placement, ['target_channel', 'external_channel', 'creative'], ) - return purchase + return placement - async def get_user_purchases( + async def get_user_placements( self, user_id: uuid.UUID, *, @@ -266,109 +266,134 @@ class Postgres(DatabaseBase): external_channel_id: uuid.UUID | None = None, creative_id: uuid.UUID | None = None, include_archived: bool = False, - ) -> list[domain.Purchase]: - stmt = ( - select(domain.Purchase) + ) -> list[domain.Placement]: + q = ( + select(domain.Placement) .options( - selectinload(domain.Purchase.target_channel), - selectinload(domain.Purchase.external_channel), - selectinload(domain.Purchase.creative), + selectinload(domain.Placement.target_channel), + selectinload(domain.Placement.external_channel), + selectinload(domain.Placement.creative), ) - .where(domain.Purchase.user_id == user_id) + .where(domain.Placement.user_id == user_id) ) if target_channel_id: - stmt = stmt.where(domain.Purchase.target_channel_id == target_channel_id) + q = q.where(domain.Placement.target_channel_id == target_channel_id) if external_channel_id: - stmt = stmt.where(domain.Purchase.external_channel_id == external_channel_id) + q = q.where(domain.Placement.external_channel_id == external_channel_id) if creative_id: - stmt = stmt.where(domain.Purchase.creative_id == creative_id) + q = q.where(domain.Placement.creative_id == creative_id) if not include_archived: - stmt = stmt.where(domain.Purchase.status == domain.PurchaseStatus.ACTIVE) + q = q.where(domain.Placement.status == domain.PlacementStatus.ACTIVE) - stmt = stmt.order_by(domain.Purchase.placement_date.desc()) + q = q.order_by(domain.Placement.placement_date.desc()) - result = await self.session.execute(stmt) + result = await self.session.execute(q) return list(result.scalars().all()) - async def delete_purchase(self, purchase_id: uuid.UUID) -> None: - stmt = delete(domain.Purchase).where(domain.Purchase.id == purchase_id) - await self.session.execute(stmt) + async def delete_placement(self, placement_id: uuid.UUID) -> None: + q = delete(domain.Placement).where(domain.Placement.id == placement_id) + await self.session.execute(q) await self.session.flush() async def check_creative_in_use(self, creative_id: uuid.UUID) -> bool: - stmt = select(domain.Purchase).where( - domain.Purchase.creative_id == creative_id, - domain.Purchase.status == domain.PurchaseStatus.ACTIVE, + q = select(domain.Placement).where( + domain.Placement.creative_id == creative_id, + domain.Placement.status == domain.PlacementStatus.ACTIVE, ) - result = await self.session.execute(stmt) + result = await self.session.execute(q) return result.scalar_one_or_none() is not None - async def get_purchase_by_invite_link(self, invite_link: str) -> domain.Purchase | None: - stmt = ( - select(domain.Purchase) + async def get_placement_by_invite_link(self, invite_link: str) -> domain.Placement | None: + q = ( + select(domain.Placement) .options( - selectinload(domain.Purchase.target_channel), - selectinload(domain.Purchase.external_channel), - selectinload(domain.Purchase.creative), + selectinload(domain.Placement.target_channel), + selectinload(domain.Placement.external_channel), + selectinload(domain.Placement.creative), ) - .where(domain.Purchase.invite_link == invite_link) + .where(domain.Placement.invite_link == invite_link) ) - result = await self.session.execute(stmt) + result = await self.session.execute(q) return result.scalar_one_or_none() + async def get_subscriber(self, telegram_id: int) -> domain.Subscriber | None: + q = select(domain.Subscriber).where(domain.Subscriber.telegram_id == telegram_id) + result = await self.session.execute(q) + return result.scalar_one_or_none() + + async def create_subscriber(self, subscriber: domain.Subscriber) -> domain.Subscriber: + self.session.add(subscriber) + await self.session.flush() + await self.session.refresh(subscriber) + return subscriber + + async def upsert_subscriber(self, subscriber: domain.Subscriber) -> domain.Subscriber: + existing = await self.get_subscriber(subscriber.telegram_id) + if existing: + # Обновляем данные если изменились + existing.username = subscriber.username + existing.first_name = subscriber.first_name + existing.last_name = subscriber.last_name + await self.session.flush() + await self.session.refresh(existing) + return existing + return await self.create_subscriber(subscriber) + async def create_subscription(self, subscription: domain.Subscription) -> domain.Subscription: self.session.add(subscription) await self.session.flush() - await self.session.refresh(subscription, ['purchase']) + await self.session.refresh(subscription, ['placement', 'subscriber']) return subscription - async def get_subscription_by_user_and_purchase( - self, user_telegram_id: int, purchase_id: uuid.UUID + async def get_subscription_by_subscriber_and_placement( + self, subscriber_id: uuid.UUID, placement_id: uuid.UUID ) -> domain.Subscription | None: - stmt = select(domain.Subscription).where( - domain.Subscription.user_telegram_id == user_telegram_id, - domain.Subscription.purchase_id == purchase_id, + q = select(domain.Subscription).where( + domain.Subscription.subscriber_id == subscriber_id, + domain.Subscription.placement_id == placement_id, ) - result = await self.session.execute(stmt) + result = await self.session.execute(q) return result.scalar_one_or_none() - async def create_views_snapshot(self, snapshot: domain.ViewsSnapshot) -> domain.ViewsSnapshot: - self.session.add(snapshot) + async def create_placement_views_history( + self, history: domain.PlacementViewsHistory + ) -> domain.PlacementViewsHistory: + self.session.add(history) await self.session.flush() - await self.session.refresh(snapshot, ['purchase']) - return snapshot + await self.session.refresh(history, ['placement']) + return history async def get_views_history( self, - purchase_id: uuid.UUID, + placement_id: uuid.UUID, *, from_date: datetime.datetime | None = None, to_date: datetime.datetime | None = None, - ) -> list[domain.ViewsSnapshot]: - stmt = select(domain.ViewsSnapshot).where(domain.ViewsSnapshot.purchase_id == purchase_id) + ) -> list[domain.PlacementViewsHistory]: + q = select(domain.PlacementViewsHistory).where(domain.PlacementViewsHistory.placement_id == placement_id) if from_date: - stmt = stmt.where(domain.ViewsSnapshot.fetched_at >= from_date) + q = q.where(domain.PlacementViewsHistory.fetched_at >= from_date) if to_date: - stmt = stmt.where(domain.ViewsSnapshot.fetched_at <= to_date) + q = q.where(domain.PlacementViewsHistory.fetched_at <= to_date) - stmt = stmt.order_by(domain.ViewsSnapshot.fetched_at.asc()) + q = q.order_by(domain.PlacementViewsHistory.fetched_at.asc()) - result = await self.session.execute(stmt) + result = await self.session.execute(q) return list(result.scalars().all()) - async def get_active_purchases_with_urls(self) -> list[domain.Purchase]: - stmt = ( - select(domain.Purchase) + async def get_active_placements_with_urls(self) -> list[domain.Placement]: + q = ( + select(domain.Placement) .where( - domain.Purchase.status == domain.PurchaseStatus.ACTIVE, - domain.Purchase.ad_post_url.isnot(None), - domain.Purchase.views_availability != domain.PostViewsAvailability.MANUAL, + domain.Placement.status == domain.PlacementStatus.ACTIVE, + domain.Placement.ad_post_url.isnot(None), + domain.Placement.views_availability != domain.PostViewsAvailability.MANUAL, ) - .order_by(domain.Purchase.created_at.asc()) + .order_by(domain.Placement.created_at.asc()) ) - result = await self.session.execute(stmt) + result = await self.session.execute(q) return list(result.scalars().all()) diff --git a/src/controller/http/__init__.py b/src/controller/http/__init__.py index c098499..fb3740a 100644 --- a/src/controller/http/__init__.py +++ b/src/controller/http/__init__.py @@ -3,7 +3,7 @@ from fastapi import APIRouter from src.controller.http.auth import auth_router from src.controller.http.creatives import creatives_router from src.controller.http.external_channels import external_channels_router -from src.controller.http.purchases import purchases_router +from src.controller.http.placements import placements_router from src.controller.http.target_channels import target_channels_router from src.controller.http.views import views_router @@ -17,7 +17,7 @@ api_v1_router = APIRouter(prefix='/api/v1') api_v1_router.include_router(target_channels_router) api_v1_router.include_router(external_channels_router) api_v1_router.include_router(creatives_router) -api_v1_router.include_router(purchases_router) +api_v1_router.include_router(placements_router) api_v1_router.include_router(views_router) api_router.include_router(api_v1_router) diff --git a/src/controller/http/placements.py b/src/controller/http/placements.py new file mode 100644 index 0000000..3846411 --- /dev/null +++ b/src/controller/http/placements.py @@ -0,0 +1,76 @@ +import uuid +from typing import Annotated + +from fastapi import Depends +from fastapi.routing import APIRouter + +from src import dependencies, dto +from src.adapter.jwt import JWTPayload + +placements_router = APIRouter(prefix='/placements', tags=['placements']) + + +@placements_router.get('') +async def list_placements( + current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)], + target_channel_id: uuid.UUID | None = None, + external_channel_id: uuid.UUID | None = None, + creative_id: uuid.UUID | None = None, + include_archived: bool = False, +) -> dto.GetPlacementsOutput: + input = dto.GetPlacementsInput( + user_id=current_user.user_id, + target_channel_id=target_channel_id, + external_channel_id=external_channel_id, + creative_id=creative_id, + include_archived=include_archived, + ) + + return await dependencies.get_usecase().get_placements(input=input) + + +@placements_router.get('/{placement_id}') +async def get_placement( + placement_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)], +) -> dto.PlacementOutput: + input = dto.GetPlacementInput( + placement_id=placement_id, + user_id=current_user.user_id, + ) + + return await dependencies.get_usecase().get_placement(input=input) + + +@placements_router.post('') +async def create_placement( + request: dto.CreatePlacementInput, + current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)], +) -> dto.PlacementOutput: + return await dependencies.get_usecase().create_placement(request, current_user.user_id) + + +@placements_router.patch('/{placement_id}') +async def update_placement( + placement_id: uuid.UUID, + request: dto.UpdatePlacementInput, + current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)], +) -> dto.PlacementOutput: + return await dependencies.get_usecase().update_placement( + placement_id=placement_id, + input=request, + user_id=current_user.user_id, + ) + + +@placements_router.delete('/{placement_id}') +async def delete_placement( + placement_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)], +) -> None: + input = dto.DeletePlacementInput( + placement_id=placement_id, + user_id=current_user.user_id, + ) + + await dependencies.get_usecase().delete_placement(input) diff --git a/src/controller/http/purchases.py b/src/controller/http/purchases.py deleted file mode 100644 index 6280af7..0000000 --- a/src/controller/http/purchases.py +++ /dev/null @@ -1,76 +0,0 @@ -import uuid -from typing import Annotated - -from fastapi import Depends -from fastapi.routing import APIRouter - -from src import dependencies, dto -from src.adapter.jwt import JWTPayload - -purchases_router = APIRouter(prefix='/purchases', tags=['purchases']) - - -@purchases_router.get('') -async def list_purchases( - current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)], - target_channel_id: uuid.UUID | None = None, - external_channel_id: uuid.UUID | None = None, - creative_id: uuid.UUID | None = None, - include_archived: bool = False, -) -> dto.GetPurchasesOutput: - input = dto.GetPurchasesInput( - user_id=current_user.user_id, - target_channel_id=target_channel_id, - external_channel_id=external_channel_id, - creative_id=creative_id, - include_archived=include_archived, - ) - - return await dependencies.get_usecase().get_purchases(input=input) - - -@purchases_router.get('/{purchase_id}') -async def get_purchase( - purchase_id: uuid.UUID, - current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)], -) -> dto.PurchaseOutput: - input = dto.GetPurchaseInput( - purchase_id=purchase_id, - user_id=current_user.user_id, - ) - - return await dependencies.get_usecase().get_purchase(input=input) - - -@purchases_router.post('') -async def create_purchase( - request: dto.CreatePurchaseInput, - current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)], -) -> dto.PurchaseOutput: - return await dependencies.get_usecase().create_purchase(request, current_user.user_id) - - -@purchases_router.patch('/{purchase_id}') -async def update_purchase( - purchase_id: uuid.UUID, - request: dto.UpdatePurchaseInput, - current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)], -) -> dto.PurchaseOutput: - return await dependencies.get_usecase().update_purchase( - purchase_id=purchase_id, - input=request, - user_id=current_user.user_id, - ) - - -@purchases_router.delete('/{purchase_id}') -async def delete_purchase( - purchase_id: uuid.UUID, - current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)], -) -> None: - input = dto.DeletePurchaseInput( - purchase_id=purchase_id, - user_id=current_user.user_id, - ) - - await dependencies.get_usecase().delete_purchase(input) diff --git a/src/controller/http/views.py b/src/controller/http/views.py index e42cf73..6a1f7a7 100644 --- a/src/controller/http/views.py +++ b/src/controller/http/views.py @@ -8,19 +8,19 @@ from fastapi.routing import APIRouter from src import dependencies, dto from src.adapter.jwt import JWTPayload -views_router = APIRouter(prefix='/purchases/{purchase_id}/views', tags=['views']) +views_router = APIRouter(prefix='/placements/{placement_id}/views', tags=['views']) @views_router.get('/history') async def get_views_history( - purchase_id: uuid.UUID, + placement_id: uuid.UUID, current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)], from_date: datetime.datetime | None = None, to_date: datetime.datetime | None = None, ) -> dto.GetViewsHistoryOutput: """Получить историю просмотров для закупа.""" input_data = dto.GetViewsHistoryInput( - purchase_id=purchase_id, + placement_id=placement_id, user_id=current_user.user_id, from_date=from_date, to_date=to_date, @@ -31,12 +31,12 @@ async def get_views_history( @views_router.post('/fetch') async def fetch_views( - purchase_id: uuid.UUID, + placement_id: uuid.UUID, current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)], ) -> dto.FetchViewsManuallyOutput: """Получить актуальные просмотры через Telegram API.""" input_data = dto.FetchViewsInputManually( - purchase_id=purchase_id, + placement_id=placement_id, user_id=current_user.user_id, ) @@ -45,13 +45,13 @@ async def fetch_views( @views_router.post('/manual') async def update_views_manually( - purchase_id: uuid.UUID, + placement_id: uuid.UUID, views_count: int, current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)], -) -> dto.PurchaseOutput: +) -> dto.PlacementOutput: """Обновить просмотры вручную.""" input_data = dto.UpdateViewsManuallyInput( - purchase_id=purchase_id, + placement_id=placement_id, user_id=current_user.user_id, views_count=views_count, ) diff --git a/src/domain/__init__.py b/src/domain/__init__.py index ae7c871..36bd695 100644 --- a/src/domain/__init__.py +++ b/src/domain/__init__.py @@ -4,12 +4,13 @@ __all__ = ( 'TargetChannel', 'ExternalChannel', 'Creative', - 'Purchase', + 'Placement', 'Subscription', - 'ViewsSnapshot', + 'Subscriber', + 'PlacementViewsHistory', 'TargetChannelStatus', 'CreativeStatus', - 'PurchaseStatus', + 'PlacementStatus', 'InviteLinkType', 'PostViewsAvailability', 'LoginToken', @@ -24,7 +25,7 @@ __all__ = ( 'ExternalChannelAlreadyExists', 'CreativeNotFound', 'CreativeInUse', - 'PurchaseNotFound', + 'PlacementNotFound', ) from .base import Base @@ -39,14 +40,15 @@ from .error import ( LoginTokenAlreadyUsed, LoginTokenExpired, LoginTokenNotFound, - PurchaseNotFound, + PlacementNotFound, TargetChannelNotFound, UserNotFound, ) from .external_channel import ExternalChannel from .login_token import LoginToken -from .purchase import InviteLinkType, PostViewsAvailability, Purchase, PurchaseStatus +from .placement import InviteLinkType, Placement, PlacementStatus, PostViewsAvailability +from .placement_views_history import PlacementViewsHistory +from .subscriber import Subscriber from .subscription import Subscription from .target_channel import TargetChannel, TargetChannelStatus from .user import User -from .views_snapshot import ViewsSnapshot diff --git a/src/domain/base.py b/src/domain/base.py index 72995b2..c124136 100644 --- a/src/domain/base.py +++ b/src/domain/base.py @@ -1,4 +1,5 @@ import datetime +import re import uuid from sqlalchemy import func @@ -6,6 +7,12 @@ from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column from sqlalchemy.types import DateTime +def _camel_to_snake(name: str) -> str: + """Convert CamelCase to snake_case.""" + name = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name) + return re.sub('([a-z0-9])([A-Z])', r'\1_\2', name).lower() + + class Base(DeclarativeBase): __abstract__ = True @@ -14,10 +21,10 @@ class Base(DeclarativeBase): datetime.datetime: DateTime(timezone=True), } - # Авто имя таблиц по названию класса + # Авто имя таблиц по названию класса в snake_case @declared_attr.directive def __tablename__(cls) -> str: - return f'{cls.__name__.lower()}s' + return _camel_to_snake(cls.__name__) id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) created_at: Mapped[datetime.datetime] = mapped_column(server_default=func.now()) diff --git a/src/domain/creative.py b/src/domain/creative.py index 0147e6a..9e8c410 100644 --- a/src/domain/creative.py +++ b/src/domain/creative.py @@ -20,8 +20,8 @@ class Creative(domain.Base): name: Mapped[str] text: Mapped[str] status: Mapped[CreativeStatus] - purchases_count: Mapped[int] = mapped_column(default=0, server_default='0') - user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('users.id'), index=True) + placements_count: Mapped[int] = mapped_column(default=0, server_default='0') + user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('user.id'), index=True) - target_channel_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('targetchannels.id'), index=True) + target_channel_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('target_channel.id'), index=True) target_channel: Mapped['TargetChannel'] = relationship(back_populates='creatives') diff --git a/src/domain/error.py b/src/domain/error.py index 495b06a..e1f39e5 100644 --- a/src/domain/error.py +++ b/src/domain/error.py @@ -56,11 +56,11 @@ def CreativeNotFound(creative_id: uuid.UUID | None = None) -> HTTPException: def CreativeInUse(creative_id: uuid.UUID) -> HTTPException: return HTTPException( status.HTTP_400_BAD_REQUEST, - f'Creative {creative_id} is used in active purchases and cannot be deleted', + f'Creative {creative_id} is used in active placements and cannot be deleted', ) -def PurchaseNotFound(purchase_id: uuid.UUID | None = None) -> HTTPException: - if purchase_id is None: - return HTTPException(status.HTTP_404_NOT_FOUND, 'Purchase not found') - return HTTPException(status.HTTP_404_NOT_FOUND, f'Purchase {purchase_id} not found') +def PlacementNotFound(placement_id: uuid.UUID | None = None) -> HTTPException: + if placement_id is None: + return HTTPException(status.HTTP_404_NOT_FOUND, 'Placement not found') + return HTTPException(status.HTTP_404_NOT_FOUND, f'Placement {placement_id} not found') diff --git a/src/domain/external_channel.py b/src/domain/external_channel.py index c85c399..ee450ae 100644 --- a/src/domain/external_channel.py +++ b/src/domain/external_channel.py @@ -9,12 +9,11 @@ from src import domain if TYPE_CHECKING: from . import TargetChannel -# M2M association table between TargetChannel and ExternalChannel -target_channel_external_channel = Table( - 'target_channel_external_channels', +target_external_channel = Table( + 'target_external_channel', domain.Base.metadata, - Column('target_channel_id', ForeignKey('targetchannels.id', ondelete='CASCADE'), primary_key=True), - Column('external_channel_id', ForeignKey('externalchannels.id', ondelete='CASCADE'), primary_key=True), + Column('target_channel_id', ForeignKey('target_channel.id', ondelete='CASCADE'), primary_key=True), + Column('external_channel_id', ForeignKey('external_channel.id', ondelete='CASCADE'), primary_key=True), ) @@ -24,10 +23,9 @@ class ExternalChannel(domain.Base): username: Mapped[str | None] = mapped_column(index=True) description: Mapped[str | None] subscribers_count: Mapped[int | None] - user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('users.id'), index=True) + user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('user.id'), index=True) - # M2M relationship with target channels target_channels: Mapped[list['TargetChannel']] = relationship( - secondary=target_channel_external_channel, + secondary=target_external_channel, back_populates='external_channels', ) diff --git a/src/domain/login_token.py b/src/domain/login_token.py index ae2120c..ca05f08 100644 --- a/src/domain/login_token.py +++ b/src/domain/login_token.py @@ -9,6 +9,6 @@ from src import domain class LoginToken(domain.Base): token: Mapped[str] = mapped_column(unique=True, index=True) - user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('users.id')) + user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('user.id')) expires_at: Mapped[datetime.datetime] used_at: Mapped[datetime.datetime | None] diff --git a/src/domain/purchase.py b/src/domain/placement.py similarity index 80% rename from src/domain/purchase.py rename to src/domain/placement.py index d1333f7..e97a56e 100644 --- a/src/domain/purchase.py +++ b/src/domain/placement.py @@ -11,10 +11,12 @@ from src import domain if TYPE_CHECKING: from .creative import Creative from .external_channel import ExternalChannel + from .placement_views_history import PlacementViewsHistory + from .subscription import Subscription from .target_channel import TargetChannel -class PurchaseStatus(StrEnum): +class PlacementStatus(StrEnum): ACTIVE = 'active' ARCHIVED = 'archived' @@ -33,12 +35,12 @@ class PostViewsAvailability(StrEnum): MANUAL = 'manual' # Просмотры вводятся вручную -class Purchase(domain.Base): +class Placement(domain.Base): # Основные поля - target_channel_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('targetchannels.id'), index=True) - external_channel_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('externalchannels.id'), index=True) - creative_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('creatives.id'), index=True) - user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('users.id'), index=True) + target_channel_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('target_channel.id'), index=True) + external_channel_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('external_channel.id'), index=True) + creative_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('creative.id'), index=True) + user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('user.id'), index=True) # Данные закупа placement_date: Mapped[datetime.datetime] # дата размещения (планируемая или фактическая) @@ -49,7 +51,7 @@ class Purchase(domain.Base): invite_link: Mapped[str] # уникальная пригласительная ссылка # Статус - status: Mapped[PurchaseStatus] = mapped_column(default=PurchaseStatus.ACTIVE) + status: Mapped[PlacementStatus] = mapped_column(default=PlacementStatus.ACTIVE) # Метрики (будут обновляться из Telegram API) subscriptions_count: Mapped[int] = mapped_column(default=0, server_default='0') @@ -63,3 +65,5 @@ class Purchase(domain.Base): target_channel: Mapped['TargetChannel'] = relationship() external_channel: Mapped['ExternalChannel'] = relationship() creative: Mapped['Creative'] = relationship() + subscriptions: Mapped[list['Subscription']] = relationship(back_populates='placement') + views_histories: Mapped[list['PlacementViewsHistory']] = relationship(back_populates='placement') diff --git a/src/domain/placement_views_history.py b/src/domain/placement_views_history.py new file mode 100644 index 0000000..4c0525f --- /dev/null +++ b/src/domain/placement_views_history.py @@ -0,0 +1,19 @@ +import datetime +import uuid +from typing import TYPE_CHECKING + +from sqlalchemy import ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src import domain + +if TYPE_CHECKING: + from .placement import Placement + + +class PlacementViewsHistory(domain.Base): + placement_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('placement.id'), index=True) + views_count: Mapped[int] # Количество просмотров на момент снимка + fetched_at: Mapped[datetime.datetime] = mapped_column(index=True) + + placement: Mapped['Placement'] = relationship(back_populates='views_histories') diff --git a/src/domain/subscriber.py b/src/domain/subscriber.py new file mode 100644 index 0000000..5da6ba7 --- /dev/null +++ b/src/domain/subscriber.py @@ -0,0 +1,21 @@ +from typing import TYPE_CHECKING + +from sqlalchemy import BigInteger +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src import domain + +if TYPE_CHECKING: + from .subscription import Subscription + + +class Subscriber(domain.Base): + """Пользователь Telegram, подписавшийся через invite links.""" + + telegram_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True) + username: Mapped[str | None] + first_name: Mapped[str | None] + last_name: Mapped[str | None] + + # Relationships + subscriptions: Mapped[list['Subscription']] = relationship(back_populates='subscriber') diff --git a/src/domain/subscription.py b/src/domain/subscription.py index 979e40f..0ff0c3b 100644 --- a/src/domain/subscription.py +++ b/src/domain/subscription.py @@ -1,22 +1,21 @@ import uuid from typing import TYPE_CHECKING -from sqlalchemy import BigInteger, ForeignKey +from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column, relationship from src import domain if TYPE_CHECKING: - from .purchase import Purchase + from .placement import Placement + from .subscriber import Subscriber class Subscription(domain.Base): - """Подписка пользователя в целевой канал через конкретный закуп.""" - - purchase_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('purchases.id'), index=True) - user_telegram_id: Mapped[int] = mapped_column(BigInteger, index=True) - username: Mapped[str | None] + placement_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('placement.id'), index=True) + subscriber_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('subscriber.id'), index=True) invite_link: Mapped[str] = mapped_column(index=True) # По которой пришёл пользователь - # Relationship - purchase: Mapped['Purchase'] = relationship() + # Relationships + placement: Mapped['Placement'] = relationship(back_populates='subscriptions') + subscriber: Mapped['Subscriber'] = relationship(back_populates='subscriptions') diff --git a/src/domain/target_channel.py b/src/domain/target_channel.py index bcd5b52..96a0696 100644 --- a/src/domain/target_channel.py +++ b/src/domain/target_channel.py @@ -8,7 +8,9 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from src import domain if TYPE_CHECKING: - from . import ExternalChannel, creative, user + from . import ExternalChannel + from .creative import Creative + from .user import User class TargetChannelStatus(StrEnum): @@ -21,24 +23,24 @@ class TargetChannel(domain.Base): telegram_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True) title: Mapped[str] username: Mapped[str | None] = mapped_column(index=True) - status: Mapped[TargetChannelStatus] + status: Mapped[TargetChannelStatus] = mapped_column(default=TargetChannelStatus.ACTIVE) # Relationship with user - user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('users.id'), index=True) - user: Mapped['user.User'] = relationship(back_populates='target_channels') + user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('user.id'), index=True) + user: Mapped['User'] = relationship(back_populates='target_channels') # M2M relationship with external channels external_channels: Mapped[list['ExternalChannel']] = relationship( - secondary='target_channel_external_channels', + secondary='target_external_channel', back_populates='target_channels', ) - creatives: Mapped[list['creative.Creative']] = relationship(back_populates='target_channel') + creatives: Mapped[list['Creative']] = relationship(back_populates='target_channel') @property def is_active(self) -> bool: - return self.status == domain.TargetChannelStatus.ACTIVE + return self.status == TargetChannelStatus.ACTIVE @is_active.setter def is_active(self, value: bool) -> None: - self.status = domain.TargetChannelStatus.ACTIVE if value else domain.TargetChannelStatus.INACTIVE + self.status = TargetChannelStatus.ACTIVE if value else TargetChannelStatus.INACTIVE diff --git a/src/domain/views_snapshot.py b/src/domain/views_snapshot.py deleted file mode 100644 index afef23a..0000000 --- a/src/domain/views_snapshot.py +++ /dev/null @@ -1,22 +0,0 @@ -import datetime -import uuid -from typing import TYPE_CHECKING - -from sqlalchemy import ForeignKey -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from src import domain - -if TYPE_CHECKING: - from .purchase import Purchase - - -class ViewsSnapshot(domain.Base): - """Снимок просмотров рекламного поста в определенный момент времени.""" - - purchase_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('purchases.id'), index=True) - views_count: Mapped[int] # Количество просмотров на момент снимка - fetched_at: Mapped[datetime.datetime] = mapped_column(index=True) # Когда получены данные - - # Relationship - purchase: Mapped['Purchase'] = relationship() diff --git a/src/dto/__init__.py b/src/dto/__init__.py index a4557ca..a98e930 100644 --- a/src/dto/__init__.py +++ b/src/dto/__init__.py @@ -24,14 +24,14 @@ __all__ = ( 'CreateCreativeInput', 'UpdateCreativeInput', 'DeleteCreativeInput', - 'PurchaseOutput', - 'GetPurchasesInput', - 'GetPurchasesOutput', - 'GetPurchaseInput', - 'CreatePurchaseInput', - 'UpdatePurchaseInput', - 'DeletePurchaseInput', - 'ViewsSnapshotOutput', + 'PlacementOutput', + 'GetPlacementsInput', + 'GetPlacementsOutput', + 'GetPlacementInput', + 'CreatePlacementInput', + 'UpdatePlacementInput', + 'DeletePlacementInput', + 'PlacementViewsHistoryOutput', 'GetViewsHistoryInput', 'GetViewsHistoryOutput', 'FetchViewsInputManually', @@ -57,14 +57,14 @@ from .external_channel import ( UpdateExternalChannelInput, UpdateExternalChannelLinksInput, ) -from .purchase import ( - CreatePurchaseInput, - DeletePurchaseInput, - GetPurchaseInput, - GetPurchasesInput, - GetPurchasesOutput, - PurchaseOutput, - UpdatePurchaseInput, +from .placement import ( + CreatePlacementInput, + DeletePlacementInput, + GetPlacementInput, + GetPlacementsInput, + GetPlacementsOutput, + PlacementOutput, + UpdatePlacementInput, ) from .target_channel import ( ChannelBotPermissions, @@ -83,6 +83,6 @@ from .views import ( FetchViewsManuallyOutput, GetViewsHistoryInput, GetViewsHistoryOutput, + PlacementViewsHistoryOutput, UpdateViewsManuallyInput, - ViewsSnapshotOutput, ) diff --git a/src/dto/creative.py b/src/dto/creative.py index 9cf2fea..d772c28 100644 --- a/src/dto/creative.py +++ b/src/dto/creative.py @@ -14,7 +14,7 @@ class CreativeOutput(pydantic.BaseModel): target_channel_title: str created_at: datetime.datetime status: domain.CreativeStatus - purchases_count: int + placements_count: int class GetCreativesInput(pydantic.BaseModel): diff --git a/src/dto/purchase.py b/src/dto/placement.py similarity index 72% rename from src/dto/purchase.py rename to src/dto/placement.py index b4e5d06..a63d402 100644 --- a/src/dto/purchase.py +++ b/src/dto/placement.py @@ -6,7 +6,7 @@ import pydantic from src import domain -class PurchaseOutput(pydantic.BaseModel): +class PlacementOutput(pydantic.BaseModel): id: uuid.UUID target_channel_id: uuid.UUID target_channel_title: str @@ -20,7 +20,7 @@ class PurchaseOutput(pydantic.BaseModel): ad_post_url: str | None invite_link_type: domain.InviteLinkType invite_link: str - status: domain.PurchaseStatus + status: domain.PlacementStatus subscriptions_count: int views_count: int | None views_availability: domain.PostViewsAvailability @@ -28,7 +28,7 @@ class PurchaseOutput(pydantic.BaseModel): created_at: datetime.datetime -class GetPurchasesInput(pydantic.BaseModel): +class GetPlacementsInput(pydantic.BaseModel): user_id: uuid.UUID target_channel_id: uuid.UUID | None = None external_channel_id: uuid.UUID | None = None @@ -36,16 +36,16 @@ class GetPurchasesInput(pydantic.BaseModel): include_archived: bool = False -class GetPurchasesOutput(pydantic.BaseModel): - purchases: list[PurchaseOutput] +class GetPlacementsOutput(pydantic.BaseModel): + placements: list[PlacementOutput] -class GetPurchaseInput(pydantic.BaseModel): - purchase_id: uuid.UUID +class GetPlacementInput(pydantic.BaseModel): + placement_id: uuid.UUID user_id: uuid.UUID -class CreatePurchaseInput(pydantic.BaseModel): +class CreatePlacementInput(pydantic.BaseModel): target_channel_id: uuid.UUID external_channel_id: uuid.UUID creative_id: uuid.UUID @@ -56,14 +56,14 @@ class CreatePurchaseInput(pydantic.BaseModel): invite_link_type: domain.InviteLinkType = domain.InviteLinkType.PUBLIC -class UpdatePurchaseInput(pydantic.BaseModel): +class UpdatePlacementInput(pydantic.BaseModel): placement_date: datetime.datetime | None = None cost: float | None = None comment: str | None = None ad_post_url: str | None = None - status: domain.PurchaseStatus | None = None + status: domain.PlacementStatus | None = None -class DeletePurchaseInput(pydantic.BaseModel): - purchase_id: uuid.UUID +class DeletePlacementInput(pydantic.BaseModel): + placement_id: uuid.UUID user_id: uuid.UUID diff --git a/src/dto/views.py b/src/dto/views.py index 1884d77..4287201 100644 --- a/src/dto/views.py +++ b/src/dto/views.py @@ -6,11 +6,11 @@ import pydantic from src import domain -class ViewsSnapshotOutput(pydantic.BaseModel): - """Снимок просмотров.""" +class PlacementViewsHistoryOutput(pydantic.BaseModel): + """История просмотров placement.""" id: uuid.UUID - purchase_id: uuid.UUID + placement_id: uuid.UUID views_count: int fetched_at: datetime.datetime created_at: datetime.datetime @@ -19,7 +19,7 @@ class ViewsSnapshotOutput(pydantic.BaseModel): class GetViewsHistoryInput(pydantic.BaseModel): """Получить историю просмотров для закупа.""" - purchase_id: uuid.UUID + placement_id: uuid.UUID user_id: uuid.UUID from_date: datetime.datetime | None = None # Фильтр: с какой даты to_date: datetime.datetime | None = None # Фильтр: по какую дату @@ -28,18 +28,18 @@ class GetViewsHistoryInput(pydantic.BaseModel): class GetViewsHistoryOutput(pydantic.BaseModel): """История просмотров.""" - snapshots: list[ViewsSnapshotOutput] + histories: list[PlacementViewsHistoryOutput] class FetchViewsInputManually(pydantic.BaseModel): """Запросить обновление просмотров для закупа.""" - purchase_id: uuid.UUID + placement_id: uuid.UUID user_id: uuid.UUID class FetchViewsManuallyOutput(pydantic.BaseModel): - purchase_id: uuid.UUID + placement_id: uuid.UUID views_count: int | None # None если не удалось получить views_availability: domain.PostViewsAvailability fetched_at: datetime.datetime @@ -49,6 +49,6 @@ class FetchViewsManuallyOutput(pydantic.BaseModel): class UpdateViewsManuallyInput(pydantic.BaseModel): """Ручное обновление просмотров.""" - purchase_id: uuid.UUID + placement_id: uuid.UUID user_id: uuid.UUID views_count: int diff --git a/src/usecase/__init__.py b/src/usecase/__init__.py index fe5e76f..d7ddc0d 100644 --- a/src/usecase/__init__.py +++ b/src/usecase/__init__.py @@ -15,11 +15,11 @@ from .external_channel.delete_external_channel import delete_external_channel from .external_channel.get_external_channels import get_external_channels from .external_channel.update_external_channel import update_external_channel from .external_channel.update_external_channel_links import update_external_channel_links -from .purchase.create_purchase import create_purchase -from .purchase.delete_purchase import delete_purchase -from .purchase.get_purchase import get_purchase -from .purchase.get_purchases import get_purchases -from .purchase.update_purchase import update_purchase +from .placement.create_placement import create_placement +from .placement.delete_placement import delete_placement +from .placement.get_placement import get_placement +from .placement.get_placements import get_placements +from .placement.update_placement import update_placement from .subscription.handle_subscription import handle_subscription from .target_channel.connect_target_chan import connect_target_chan from .target_channel.disconnect_target_chan import disconnect_target_chan @@ -95,13 +95,13 @@ class Database(typing.Protocol): async def delete_creative(self, creative_id: UUID) -> None: ... - async def get_purchase(self, user_id: UUID, purchase_id: UUID) -> domain.Purchase | None: ... + async def get_placement(self, user_id: UUID, placement_id: UUID) -> domain.Placement | None: ... - async def create_purchase(self, purchase: domain.Purchase) -> domain.Purchase: ... + async def create_placement(self, placement: domain.Placement) -> domain.Placement: ... - async def update_purchase(self, purchase: domain.Purchase) -> domain.Purchase: ... + async def update_placement(self, placement: domain.Placement) -> domain.Placement: ... - async def get_user_purchases( + async def get_user_placements( self, user_id: UUID, *, @@ -109,31 +109,39 @@ class Database(typing.Protocol): external_channel_id: UUID | None = None, creative_id: UUID | None = None, include_archived: bool = False, - ) -> list[domain.Purchase]: ... + ) -> list[domain.Placement]: ... - async def delete_purchase(self, purchase_id: UUID) -> None: ... + async def delete_placement(self, placement_id: UUID) -> None: ... async def check_creative_in_use(self, creative_id: UUID) -> bool: ... - async def get_purchase_by_invite_link(self, invite_link: str) -> domain.Purchase | None: ... + async def get_placement_by_invite_link(self, invite_link: str) -> domain.Placement | None: ... + + async def get_subscriber(self, telegram_id: int) -> domain.Subscriber | None: ... + + async def create_subscriber(self, subscriber: domain.Subscriber) -> domain.Subscriber: ... + + async def upsert_subscriber(self, subscriber: domain.Subscriber) -> domain.Subscriber: ... async def create_subscription(self, subscription: domain.Subscription) -> domain.Subscription: ... - async def get_subscription_by_user_and_purchase( - self, user_telegram_id: int, purchase_id: UUID + async def get_subscription_by_subscriber_and_placement( + self, subscriber_id: UUID, placement_id: UUID ) -> domain.Subscription | None: ... - async def create_views_snapshot(self, snapshot: domain.ViewsSnapshot) -> domain.ViewsSnapshot: ... + async def create_placement_views_history( + self, history: domain.PlacementViewsHistory + ) -> domain.PlacementViewsHistory: ... async def get_views_history( self, - purchase_id: UUID, + placement_id: UUID, *, from_date: datetime.datetime | None = None, to_date: datetime.datetime | None = None, - ) -> list[domain.ViewsSnapshot]: ... + ) -> list[domain.PlacementViewsHistory]: ... - async def get_active_purchases_with_urls(self) -> list[domain.Purchase]: ... # Для worker + async def get_active_placements_with_urls(self) -> list[domain.Placement]: ... # Для worker class TelegramBotWriter(typing.Protocol): @@ -178,11 +186,11 @@ class Usecase: create_creative = create_creative update_creative = update_creative delete_creative = delete_creative - get_purchases = get_purchases - get_purchase = get_purchase - create_purchase = create_purchase - update_purchase = update_purchase - delete_purchase = delete_purchase + get_placements = get_placements + get_placement = get_placement + create_placement = create_placement + update_placement = update_placement + delete_placement = delete_placement handle_subscription = handle_subscription fetch_views = fetch_views_manually run_views_worker_cycle = run_views_worker_cycle diff --git a/src/usecase/creative/create_creative.py b/src/usecase/creative/create_creative.py index 79d8ed3..16aef42 100644 --- a/src/usecase/creative/create_creative.py +++ b/src/usecase/creative/create_creative.py @@ -37,5 +37,5 @@ async def create_creative(self: 'Usecase', input: dto.CreateCreativeInput, user_ target_channel_title=created.target_channel.title, created_at=created.created_at, status=created.status, - purchases_count=created.purchases_count, + placements_count=created.placements_count, ) diff --git a/src/usecase/creative/delete_creative.py b/src/usecase/creative/delete_creative.py index 9dbbd48..4861e60 100644 --- a/src/usecase/creative/delete_creative.py +++ b/src/usecase/creative/delete_creative.py @@ -16,8 +16,8 @@ async def delete_creative(self: 'Usecase', input: dto.DeleteCreativeInput) -> No log.warning('User %s attempted to delete unavailable creative %s', input.user_id, input.creative_id) raise domain.CreativeNotFound(input.creative_id) - if creative.purchases_count > 0: - log.warning('Creative %s is used in purchases and cannot be deleted', input.creative_id) + if creative.placements_count > 0: + log.warning('Creative %s is used in placements and cannot be deleted', input.creative_id) raise domain.CreativeInUse(input.creative_id) await self.database.delete_creative(input.creative_id) diff --git a/src/usecase/creative/get_creative.py b/src/usecase/creative/get_creative.py index 8e7185b..85b285b 100644 --- a/src/usecase/creative/get_creative.py +++ b/src/usecase/creative/get_creative.py @@ -24,5 +24,5 @@ async def get_creative(self: 'Usecase', input: dto.GetCreativeInput) -> dto.Crea target_channel_title=creative.target_channel.title, created_at=creative.created_at, status=creative.status, - purchases_count=creative.purchases_count, + placements_count=creative.placements_count, ) diff --git a/src/usecase/creative/get_creatives.py b/src/usecase/creative/get_creatives.py index 1d92a7e..5106d95 100644 --- a/src/usecase/creative/get_creatives.py +++ b/src/usecase/creative/get_creatives.py @@ -22,7 +22,7 @@ async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> dto.Ge target_channel_title=creative.target_channel.title, created_at=creative.created_at, status=creative.status, - purchases_count=creative.purchases_count, + placements_count=creative.placements_count, ) for creative in creatives ] diff --git a/src/usecase/creative/update_creative.py b/src/usecase/creative/update_creative.py index 57cac16..fad6a82 100644 --- a/src/usecase/creative/update_creative.py +++ b/src/usecase/creative/update_creative.py @@ -36,5 +36,5 @@ async def update_creative( target_channel_title=updated.target_channel.title, created_at=updated.created_at, status=updated.status, - purchases_count=updated.purchases_count, + placements_count=updated.placements_count, ) diff --git a/src/usecase/purchase/create_purchase.py b/src/usecase/placement/create_placement.py similarity index 81% rename from src/usecase/purchase/create_purchase.py rename to src/usecase/placement/create_placement.py index 381ed90..1a93500 100644 --- a/src/usecase/purchase/create_purchase.py +++ b/src/usecase/placement/create_placement.py @@ -10,13 +10,13 @@ if TYPE_CHECKING: log = logging.getLogger(__name__) -async def create_purchase(self: 'Usecase', input: dto.CreatePurchaseInput, user_id: uuid.UUID) -> dto.PurchaseOutput: +async def create_placement(self: 'Usecase', input: dto.CreatePlacementInput, user_id: uuid.UUID) -> dto.PlacementOutput: async with self.database.transaction(): # Проверяем существование целевого канала target_channel = await self.database.get_target_channel(user_id, channel_id=input.target_channel_id) if not target_channel: log.warning( - 'User %s attempted to create purchase for unavailable target %s', user_id, input.target_channel_id + 'User %s attempted to create placement for unavailable target %s', user_id, input.target_channel_id ) raise domain.TargetChannelNotFound(input.target_channel_id) @@ -24,14 +24,14 @@ async def create_purchase(self: 'Usecase', input: dto.CreatePurchaseInput, user_ external_channel = await self.database.get_external_channel(user_id, channel_id=input.external_channel_id) if not external_channel: log.warning( - 'User %s attempted to create purchase for unavailable external %s', user_id, input.external_channel_id + 'User %s attempted to create placement for unavailable external %s', user_id, input.external_channel_id ) raise domain.ExternalChannelNotFound(input.external_channel_id) # Проверяем существование креатива creative = await self.database.get_creative(user_id, input.creative_id) if not creative: - log.warning('User %s attempted to create purchase for unavailable creative %s', user_id, input.creative_id) + log.warning('User %s attempted to create placement for unavailable creative %s', user_id, input.creative_id) raise domain.CreativeNotFound(input.creative_id) # Создаём уникальную пригласительную ссылку через Telegram Bot API @@ -42,7 +42,7 @@ async def create_purchase(self: 'Usecase', input: dto.CreatePurchaseInput, user_ ) # Создаём закуп - purchase = domain.Purchase( + placement = domain.Placement( target_channel_id=input.target_channel_id, external_channel_id=input.external_channel_id, creative_id=input.creative_id, @@ -53,17 +53,17 @@ async def create_purchase(self: 'Usecase', input: dto.CreatePurchaseInput, user_ ad_post_url=input.ad_post_url, invite_link_type=input.invite_link_type, invite_link=invite_link, - status=domain.PurchaseStatus.ACTIVE, + status=domain.PlacementStatus.ACTIVE, ) - created = await self.database.create_purchase(purchase) + created = await self.database.create_placement(placement) # Увеличиваем счётчик закупов у креатива - creative.purchases_count += 1 + creative.placements_count += 1 await self.database.update_creative(creative) # Формируем ответ внутри транзакции, пока объекты в сессии - return dto.PurchaseOutput( + return dto.PlacementOutput( id=created.id, target_channel_id=created.target_channel_id, target_channel_title=target_channel.title, diff --git a/src/usecase/placement/delete_placement.py b/src/usecase/placement/delete_placement.py new file mode 100644 index 0000000..91eb8ea --- /dev/null +++ b/src/usecase/placement/delete_placement.py @@ -0,0 +1,26 @@ +import logging +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def delete_placement(self: 'Usecase', input: dto.DeletePlacementInput) -> None: + async with self.database.transaction(): + placement = await self.database.get_placement(input.user_id, input.placement_id) + if not placement: + log.warning('Placement %s not found for user %s', input.placement_id, input.user_id) + raise domain.PlacementNotFound(input.placement_id) + + # Уменьшаем счётчик закупов у креатива, если закуп был активным + if placement.status == domain.PlacementStatus.ACTIVE: + creative = await self.database.get_creative(input.user_id, placement.creative_id) + if creative and creative.placements_count > 0: + creative.placements_count -= 1 + await self.database.update_creative(creative) + + await self.database.delete_placement(input.placement_id) diff --git a/src/usecase/placement/get_placement.py b/src/usecase/placement/get_placement.py new file mode 100644 index 0000000..5b7999c --- /dev/null +++ b/src/usecase/placement/get_placement.py @@ -0,0 +1,39 @@ +import logging +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def get_placement(self: 'Usecase', input: dto.GetPlacementInput) -> dto.PlacementOutput: + async with self.database.transaction(): + placement = await self.database.get_placement(input.user_id, input.placement_id) + if not placement: + log.warning('Placement %s not found for user %s', input.placement_id, input.user_id) + raise domain.PlacementNotFound(input.placement_id) + + return dto.PlacementOutput( + id=placement.id, + target_channel_id=placement.target_channel_id, + target_channel_title=placement.target_channel.title, + external_channel_id=placement.external_channel_id, + external_channel_title=placement.external_channel.title, + creative_id=placement.creative_id, + creative_name=placement.creative.name, + placement_date=placement.placement_date, + cost=placement.cost, + comment=placement.comment, + ad_post_url=placement.ad_post_url, + invite_link_type=placement.invite_link_type, + invite_link=placement.invite_link, + status=placement.status, + subscriptions_count=placement.subscriptions_count, + views_count=placement.views_count, + views_availability=placement.views_availability, + last_views_fetch_at=placement.last_views_fetch_at, + created_at=placement.created_at, + ) diff --git a/src/usecase/placement/get_placements.py b/src/usecase/placement/get_placements.py new file mode 100644 index 0000000..dd0cd1b --- /dev/null +++ b/src/usecase/placement/get_placements.py @@ -0,0 +1,44 @@ +from typing import TYPE_CHECKING + +from src import dto + +if TYPE_CHECKING: + from .. import Usecase + + +async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.GetPlacementsOutput: + async with self.database.transaction(): + placements = await self.database.get_user_placements( + input.user_id, + target_channel_id=input.target_channel_id, + external_channel_id=input.external_channel_id, + creative_id=input.creative_id, + include_archived=input.include_archived, + ) + + return dto.GetPlacementsOutput( + placements=[ + dto.PlacementOutput( + id=placement.id, + target_channel_id=placement.target_channel_id, + target_channel_title=placement.target_channel.title, + external_channel_id=placement.external_channel_id, + external_channel_title=placement.external_channel.title, + creative_id=placement.creative_id, + creative_name=placement.creative.name, + placement_date=placement.placement_date, + cost=placement.cost, + comment=placement.comment, + ad_post_url=placement.ad_post_url, + invite_link_type=placement.invite_link_type, + invite_link=placement.invite_link, + status=placement.status, + subscriptions_count=placement.subscriptions_count, + views_count=placement.views_count, + views_availability=placement.views_availability, + last_views_fetch_at=placement.last_views_fetch_at, + created_at=placement.created_at, + ) + for placement in placements + ] + ) diff --git a/src/usecase/purchase/update_purchase.py b/src/usecase/placement/update_placement.py similarity index 66% rename from src/usecase/purchase/update_purchase.py rename to src/usecase/placement/update_placement.py index 03d5a7a..56d15bd 100644 --- a/src/usecase/purchase/update_purchase.py +++ b/src/usecase/placement/update_placement.py @@ -10,30 +10,30 @@ if TYPE_CHECKING: log = logging.getLogger(__name__) -async def update_purchase( - self: 'Usecase', purchase_id: uuid.UUID, input: dto.UpdatePurchaseInput, user_id: uuid.UUID -) -> dto.PurchaseOutput: +async def update_placement( + self: 'Usecase', placement_id: uuid.UUID, input: dto.UpdatePlacementInput, user_id: uuid.UUID +) -> dto.PlacementOutput: async with self.database.transaction(): - purchase = await self.database.get_purchase(user_id, purchase_id) - if not purchase: - log.warning('Purchase %s not found for user %s', purchase_id, user_id) - raise domain.PurchaseNotFound(purchase_id) + placement = await self.database.get_placement(user_id, placement_id) + if not placement: + log.warning('Placement %s not found for user %s', placement_id, user_id) + raise domain.PlacementNotFound(placement_id) # Обновляем только те поля, которые были переданы if input.placement_date is not None: - purchase.placement_date = input.placement_date + placement.placement_date = input.placement_date if input.cost is not None: - purchase.cost = input.cost + placement.cost = input.cost if input.comment is not None: - purchase.comment = input.comment + placement.comment = input.comment if input.ad_post_url is not None: - purchase.ad_post_url = input.ad_post_url + placement.ad_post_url = input.ad_post_url if input.status is not None: - purchase.status = input.status + placement.status = input.status - updated = await self.database.update_purchase(purchase) + updated = await self.database.update_placement(placement) - return dto.PurchaseOutput( + return dto.PlacementOutput( id=updated.id, target_channel_id=updated.target_channel_id, target_channel_title=updated.target_channel.title, diff --git a/src/usecase/purchase/delete_purchase.py b/src/usecase/purchase/delete_purchase.py deleted file mode 100644 index f8f91cd..0000000 --- a/src/usecase/purchase/delete_purchase.py +++ /dev/null @@ -1,26 +0,0 @@ -import logging -from typing import TYPE_CHECKING - -from src import domain, dto - -if TYPE_CHECKING: - from .. import Usecase - -log = logging.getLogger(__name__) - - -async def delete_purchase(self: 'Usecase', input: dto.DeletePurchaseInput) -> None: - async with self.database.transaction(): - purchase = await self.database.get_purchase(input.user_id, input.purchase_id) - if not purchase: - log.warning('Purchase %s not found for user %s', input.purchase_id, input.user_id) - raise domain.PurchaseNotFound(input.purchase_id) - - # Уменьшаем счётчик закупов у креатива, если закуп был активным - if purchase.status == domain.PurchaseStatus.ACTIVE: - creative = await self.database.get_creative(input.user_id, purchase.creative_id) - if creative and creative.purchases_count > 0: - creative.purchases_count -= 1 - await self.database.update_creative(creative) - - await self.database.delete_purchase(input.purchase_id) diff --git a/src/usecase/purchase/get_purchase.py b/src/usecase/purchase/get_purchase.py deleted file mode 100644 index bb332f4..0000000 --- a/src/usecase/purchase/get_purchase.py +++ /dev/null @@ -1,39 +0,0 @@ -import logging -from typing import TYPE_CHECKING - -from src import domain, dto - -if TYPE_CHECKING: - from .. import Usecase - -log = logging.getLogger(__name__) - - -async def get_purchase(self: 'Usecase', input: dto.GetPurchaseInput) -> dto.PurchaseOutput: - async with self.database.transaction(): - purchase = await self.database.get_purchase(input.user_id, input.purchase_id) - if not purchase: - log.warning('Purchase %s not found for user %s', input.purchase_id, input.user_id) - raise domain.PurchaseNotFound(input.purchase_id) - - return dto.PurchaseOutput( - id=purchase.id, - target_channel_id=purchase.target_channel_id, - target_channel_title=purchase.target_channel.title, - external_channel_id=purchase.external_channel_id, - external_channel_title=purchase.external_channel.title, - creative_id=purchase.creative_id, - creative_name=purchase.creative.name, - placement_date=purchase.placement_date, - cost=purchase.cost, - comment=purchase.comment, - ad_post_url=purchase.ad_post_url, - invite_link_type=purchase.invite_link_type, - invite_link=purchase.invite_link, - status=purchase.status, - subscriptions_count=purchase.subscriptions_count, - views_count=purchase.views_count, - views_availability=purchase.views_availability, - last_views_fetch_at=purchase.last_views_fetch_at, - created_at=purchase.created_at, - ) diff --git a/src/usecase/purchase/get_purchases.py b/src/usecase/purchase/get_purchases.py deleted file mode 100644 index 941c2d6..0000000 --- a/src/usecase/purchase/get_purchases.py +++ /dev/null @@ -1,44 +0,0 @@ -from typing import TYPE_CHECKING - -from src import dto - -if TYPE_CHECKING: - from .. import Usecase - - -async def get_purchases(self: 'Usecase', input: dto.GetPurchasesInput) -> dto.GetPurchasesOutput: - async with self.database.transaction(): - purchases = await self.database.get_user_purchases( - input.user_id, - target_channel_id=input.target_channel_id, - external_channel_id=input.external_channel_id, - creative_id=input.creative_id, - include_archived=input.include_archived, - ) - - return dto.GetPurchasesOutput( - purchases=[ - dto.PurchaseOutput( - id=purchase.id, - target_channel_id=purchase.target_channel_id, - target_channel_title=purchase.target_channel.title, - external_channel_id=purchase.external_channel_id, - external_channel_title=purchase.external_channel.title, - creative_id=purchase.creative_id, - creative_name=purchase.creative.name, - placement_date=purchase.placement_date, - cost=purchase.cost, - comment=purchase.comment, - ad_post_url=purchase.ad_post_url, - invite_link_type=purchase.invite_link_type, - invite_link=purchase.invite_link, - status=purchase.status, - subscriptions_count=purchase.subscriptions_count, - views_count=purchase.views_count, - views_availability=purchase.views_availability, - last_views_fetch_at=purchase.last_views_fetch_at, - created_at=purchase.created_at, - ) - for purchase in purchases - ] - ) diff --git a/src/usecase/subscription/handle_subscription.py b/src/usecase/subscription/handle_subscription.py index d7ca60f..02d4de1 100644 --- a/src/usecase/subscription/handle_subscription.py +++ b/src/usecase/subscription/handle_subscription.py @@ -14,6 +14,8 @@ async def handle_subscription( user_telegram_id: int, username: str | None, invite_link: str, + first_name: str | None = None, + last_name: str | None = None, ) -> None: """ Обрабатывает подписку пользователя в целевой канал. @@ -23,37 +25,48 @@ async def handle_subscription( """ async with self.database.transaction(): # Находим закуп по пригласительной ссылке - purchase = await self.database.get_purchase_by_invite_link(invite_link) - if not purchase: - log.warning('Purchase not found for invite_link: %s', invite_link) + placement = await self.database.get_placement_by_invite_link(invite_link) + if not placement: + log.warning('Placement not found for invite_link: %s', invite_link) return + # Создаём или обновляем подписчика + subscriber = domain.Subscriber( + telegram_id=user_telegram_id, + username=username, + first_name=first_name, + last_name=last_name, + ) + subscriber = await self.database.upsert_subscriber(subscriber) + # Проверяем, не создана ли уже подписка для этого пользователя - existing_subscription = await self.database.get_subscription_by_user_and_purchase(user_telegram_id, purchase.id) + existing_subscription = await self.database.get_subscription_by_subscriber_and_placement( + subscriber.id, placement.id + ) if existing_subscription: log.info( - 'Subscription already exists for user %s and purchase %s', - user_telegram_id, - purchase.id, + 'Subscription already exists for subscriber %s and placement %s', + subscriber.id, + placement.id, ) return # Создаём запись о подписке subscription = domain.Subscription( - purchase_id=purchase.id, - user_telegram_id=user_telegram_id, - username=username, + placement_id=placement.id, + subscriber_id=subscriber.id, invite_link=invite_link, ) await self.database.create_subscription(subscription) # Увеличиваем счётчик подписок у закупа - purchase.subscriptions_count += 1 - await self.database.update_purchase(purchase) + placement.subscriptions_count += 1 + await self.database.update_placement(placement) log.info( - 'Subscription created: user %s subscribed via purchase %s (invite_link: %s)', + 'Subscription created: subscriber %s (telegram_id: %s) subscribed via placement %s (invite_link: %s)', + subscriber.id, user_telegram_id, - purchase.id, + placement.id, invite_link, ) diff --git a/src/usecase/views/fetch_views_manually.py b/src/usecase/views/fetch_views_manually.py index 274e7b6..c38daf0 100644 --- a/src/usecase/views/fetch_views_manually.py +++ b/src/usecase/views/fetch_views_manually.py @@ -12,52 +12,52 @@ log = logging.getLogger(__name__) async def fetch_views_manually(self: 'Usecase', input: dto.FetchViewsInputManually) -> dto.FetchViewsManuallyOutput: async with self.database.transaction(): - purchase = await self.database.get_purchase(input.user_id, input.purchase_id) - if not purchase: - raise domain.PurchaseNotFound(input.purchase_id) + placement = await self.database.get_placement(input.user_id, input.placement_id) + if not placement: + raise domain.PlacementNotFound(input.placement_id) - if not purchase.ad_post_url: + if not placement.ad_post_url: return dto.FetchViewsManuallyOutput( - purchase_id=purchase.id, + placement_id=placement.id, views_count=None, views_availability=domain.PostViewsAvailability.UNAVAILABLE, fetched_at=datetime.datetime.now(datetime.UTC), error_message='No ad_post_url specified', ) - views_count = await self.telegram_parser.get_post_views(purchase.ad_post_url) + views_count = await self.telegram_parser.get_post_views(placement.ad_post_url) fetched_at = datetime.datetime.now(datetime.UTC) if views_count: - purchase.views_count = views_count - purchase.views_availability = domain.PostViewsAvailability.AVAILABLE - purchase.last_views_fetch_at = fetched_at - await self.database.update_purchase(purchase) + placement.views_count = views_count + placement.views_availability = domain.PostViewsAvailability.AVAILABLE + placement.last_views_fetch_at = fetched_at + await self.database.update_placement(placement) - snapshot = domain.ViewsSnapshot( - purchase_id=purchase.id, + history = domain.PlacementViewsHistory( + placement_id=placement.id, views_count=views_count, fetched_at=fetched_at, ) - await self.database.create_views_snapshot(snapshot) + await self.database.create_placement_views_history(history) - log.info('Views fetched for purchase %s: %d', purchase.id, views_count) + log.info('Views fetched for placement %s: %d', placement.id, views_count) return dto.FetchViewsManuallyOutput( - purchase_id=purchase.id, + placement_id=placement.id, views_count=views_count, views_availability=domain.PostViewsAvailability.AVAILABLE, fetched_at=fetched_at, ) - purchase.views_availability = domain.PostViewsAvailability.UNAVAILABLE - purchase.last_views_fetch_at = fetched_at - await self.database.update_purchase(purchase) + placement.views_availability = domain.PostViewsAvailability.UNAVAILABLE + placement.last_views_fetch_at = fetched_at + await self.database.update_placement(placement) - log.warning('Failed to fetch views for purchase %s', purchase.id) + log.warning('Failed to fetch views for placement %s', placement.id) return dto.FetchViewsManuallyOutput( - purchase_id=purchase.id, + placement_id=placement.id, views_count=None, views_availability=domain.PostViewsAvailability.UNAVAILABLE, fetched_at=fetched_at, diff --git a/src/usecase/views/get_views_history.py b/src/usecase/views/get_views_history.py index 09a2cc2..06c7a02 100644 --- a/src/usecase/views/get_views_history.py +++ b/src/usecase/views/get_views_history.py @@ -12,26 +12,26 @@ log = logging.getLogger(__name__) async def get_views_history(self: 'Usecase', input: dto.GetViewsHistoryInput) -> dto.GetViewsHistoryOutput: """Получить историю просмотров для закупа.""" async with self.database.transaction(): - purchase = await self.database.get_purchase(input.user_id, input.purchase_id) - if not purchase: - log.warning('Purchase %s not found for user %s', input.purchase_id, input.user_id) - raise domain.PurchaseNotFound(input.purchase_id) + placement = await self.database.get_placement(input.user_id, input.placement_id) + if not placement: + log.warning('Placement %s not found for user %s', input.placement_id, input.user_id) + raise domain.PlacementNotFound(input.placement_id) - snapshots = await self.database.get_views_history( - input.purchase_id, + histories = await self.database.get_views_history( + input.placement_id, from_date=input.from_date, to_date=input.to_date, ) return dto.GetViewsHistoryOutput( - snapshots=[ - dto.ViewsSnapshotOutput( - id=snapshot.id, - purchase_id=snapshot.purchase_id, - views_count=snapshot.views_count, - fetched_at=snapshot.fetched_at, - created_at=snapshot.created_at, + histories=[ + dto.PlacementViewsHistoryOutput( + id=history.id, + placement_id=history.placement_id, + views_count=history.views_count, + fetched_at=history.fetched_at, + created_at=history.created_at, ) - for snapshot in snapshots + for history in histories ] ) diff --git a/src/usecase/views/run_views_worker_cycle.py b/src/usecase/views/run_views_worker_cycle.py index 5ce7f32..ceff28e 100644 --- a/src/usecase/views/run_views_worker_cycle.py +++ b/src/usecase/views/run_views_worker_cycle.py @@ -1,6 +1,7 @@ import asyncio import datetime import logging +import uuid from typing import TYPE_CHECKING, cast from aiolimiter import AsyncLimiter @@ -13,53 +14,55 @@ if TYPE_CHECKING: log = logging.getLogger(__name__) -async def _process_purchase(self: 'Usecase', purchase_id, user_id, ad_post_url: str): +async def _process_placement( + self: 'Usecase', placement_id: uuid.UUID, user_id: uuid.UUID, ad_post_url: str +) -> None: views_count = await self.telegram_parser.get_post_views(ad_post_url) fetched_at = datetime.datetime.now(datetime.UTC) async with self.database.transaction(): - p = await self.database.get_purchase(user_id=user_id, purchase_id=purchase_id) + p = await self.database.get_placement(user_id=user_id, placement_id=placement_id) if not p: - log.error('Purchase %s not found', purchase_id) + log.error('Placement %s not found', placement_id) return p.last_views_fetch_at = fetched_at if not views_count: p.views_availability = domain.PostViewsAvailability.UNAVAILABLE - await self.database.update_purchase(p) - log.warning('Failed to fetch views for %s', purchase_id) + await self.database.update_placement(p) + log.warning('Failed to fetch views for %s', placement_id) return p.views_count = views_count p.views_availability = domain.PostViewsAvailability.AVAILABLE - await self.database.update_purchase(p) + await self.database.update_placement(p) - snapshot = domain.ViewsSnapshot( - purchase_id=purchase_id, + history = domain.PlacementViewsHistory( + placement_id=placement_id, views_count=views_count, fetched_at=fetched_at, ) - await self.database.create_views_snapshot(snapshot) + await self.database.create_placement_views_history(history) - log.info('Updated purchase %s: %d', purchase_id, views_count) + log.info('Updated placement %s: %d', placement_id, views_count) async def run_views_worker_cycle(self: 'Usecase', interval_seconds: int) -> None: async with self.database.transaction(): - purchases = await self.database.get_active_purchases_with_urls() + placements = await self.database.get_active_placements_with_urls() - if not purchases: - log.debug('ViewsWorkerCycle: No purchases to process') + if not placements: + log.debug('ViewsWorkerCycle: No placements to process') return - limiter = AsyncLimiter(max_rate=len(purchases), time_period=interval_seconds) - log.info('Processing %d purchases for %d seconds', len(purchases), interval_seconds) + limiter = AsyncLimiter(max_rate=len(placements), time_period=interval_seconds) + log.info('Processing %d placements for %d seconds', len(placements), interval_seconds) - async def process(p): + async def process(p: domain.Placement) -> None: async with limiter: try: - await _process_purchase(self, p.id, p.user_id, cast(str, p.ad_post_url)) + await _process_placement(self, p.id, p.user_id, cast(str, p.ad_post_url)) except Exception: log.exception('Error updating views for %s', p.id) - await asyncio.gather(*(process(p) for p in purchases)) + await asyncio.gather(*(process(p) for p in placements)) diff --git a/src/usecase/views/update_views_manually.py b/src/usecase/views/update_views_manually.py index de824f3..87988be 100644 --- a/src/usecase/views/update_views_manually.py +++ b/src/usecase/views/update_views_manually.py @@ -10,47 +10,47 @@ if TYPE_CHECKING: log = logging.getLogger(__name__) -async def update_views_manually(self: 'Usecase', input: dto.UpdateViewsManuallyInput) -> dto.PurchaseOutput: +async def update_views_manually(self: 'Usecase', input: dto.UpdateViewsManuallyInput) -> dto.PlacementOutput: async with self.database.transaction(): - purchase = await self.database.get_purchase(input.user_id, input.purchase_id) - if not purchase: - log.warning('Purchase %s not found for user %s', input.purchase_id, input.user_id) - raise domain.PurchaseNotFound(input.purchase_id) + placement = await self.database.get_placement(input.user_id, input.placement_id) + if not placement: + log.warning('Placement %s not found for user %s', input.placement_id, input.user_id) + raise domain.PlacementNotFound(input.placement_id) fetched_at = datetime.datetime.now(datetime.UTC) - purchase.views_count = input.views_count - purchase.views_availability = domain.PostViewsAvailability.MANUAL - purchase.last_views_fetch_at = fetched_at - await self.database.update_purchase(purchase) + placement.views_count = input.views_count + placement.views_availability = domain.PostViewsAvailability.MANUAL + placement.last_views_fetch_at = fetched_at + await self.database.update_placement(placement) - snapshot = domain.ViewsSnapshot( - purchase_id=purchase.id, + history = domain.PlacementViewsHistory( + placement_id=placement.id, views_count=input.views_count, fetched_at=fetched_at, ) - await self.database.create_views_snapshot(snapshot) + await self.database.create_placement_views_history(history) - log.info('Views manually updated for purchase %s: %d', purchase.id, input.views_count) + log.info('Views manually updated for placement %s: %d', placement.id, input.views_count) - return dto.PurchaseOutput( - id=purchase.id, - target_channel_id=purchase.target_channel_id, - target_channel_title=purchase.target_channel.title, - external_channel_id=purchase.external_channel_id, - external_channel_title=purchase.external_channel.title, - creative_id=purchase.creative_id, - creative_name=purchase.creative.name, - placement_date=purchase.placement_date, - cost=purchase.cost, - comment=purchase.comment, - ad_post_url=purchase.ad_post_url, - invite_link_type=purchase.invite_link_type, - invite_link=purchase.invite_link, - status=purchase.status, - subscriptions_count=purchase.subscriptions_count, - views_count=purchase.views_count, - views_availability=purchase.views_availability, - last_views_fetch_at=purchase.last_views_fetch_at, - created_at=purchase.created_at, + return dto.PlacementOutput( + id=placement.id, + target_channel_id=placement.target_channel_id, + target_channel_title=placement.target_channel.title, + external_channel_id=placement.external_channel_id, + external_channel_title=placement.external_channel.title, + creative_id=placement.creative_id, + creative_name=placement.creative.name, + placement_date=placement.placement_date, + cost=placement.cost, + comment=placement.comment, + ad_post_url=placement.ad_post_url, + invite_link_type=placement.invite_link_type, + invite_link=placement.invite_link, + status=placement.status, + subscriptions_count=placement.subscriptions_count, + views_count=placement.views_count, + views_availability=placement.views_availability, + last_views_fetch_at=placement.last_views_fetch_at, + created_at=placement.created_at, ) diff --git a/tests/conftest.py b/tests/conftest.py index 4bc6043..cfb9499 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,9 +18,11 @@ class MockDatabase: self.target_channels_by_telegram_id: dict[int, domain.TargetChannel] = {} self.external_channels: dict[uuid.UUID, domain.ExternalChannel] = {} self.creatives: dict[uuid.UUID, domain.Creative] = {} - self.purchases: dict[uuid.UUID, domain.Purchase] = {} + self.placements: dict[uuid.UUID, domain.Placement] = {} + self.subscribers: dict[uuid.UUID, domain.Subscriber] = {} + self.subscribers_by_telegram_id: dict[int, domain.Subscriber] = {} self.subscriptions: dict[uuid.UUID, domain.Subscription] = {} - self.views_snapshots: dict[uuid.UUID, domain.ViewsSnapshot] = {} + self.placement_views_histories: dict[uuid.UUID, domain.PlacementViewsHistory] = {} self.m2m_target_external: dict[uuid.UUID, list[uuid.UUID]] = {} def transaction(self) -> AbstractAsyncContextManager[None]: @@ -159,8 +161,8 @@ class MockDatabase: creative.created_at = datetime.datetime.now(datetime.UTC) if not hasattr(creative, 'updated_at') or creative.updated_at is None: creative.updated_at = datetime.datetime.now(datetime.UTC) - if not hasattr(creative, 'purchases_count') or creative.purchases_count is None: - creative.purchases_count = 0 + if not hasattr(creative, 'placements_count') or creative.placements_count is None: + creative.placements_count = 0 target_ch = self.target_channels.get(creative.target_channel_id) if target_ch: creative.target_channel = target_ch @@ -196,43 +198,43 @@ class MockDatabase: async def delete_creative(self, creative_id: uuid.UUID) -> None: self.creatives.pop(creative_id, None) - async def create_purchase(self, purchase: domain.Purchase) -> domain.Purchase: - if not purchase.id: - purchase.id = uuid.uuid4() - if not hasattr(purchase, 'created_at') or purchase.created_at is None: - purchase.created_at = datetime.datetime.now(datetime.UTC) - if not hasattr(purchase, 'updated_at') or purchase.updated_at is None: - purchase.updated_at = datetime.datetime.now(datetime.UTC) - if not hasattr(purchase, 'subscriptions_count') or purchase.subscriptions_count is None: - purchase.subscriptions_count = 0 - if not hasattr(purchase, 'status') or purchase.status is None: - purchase.status = domain.PurchaseStatus.ACTIVE - if not hasattr(purchase, 'views_availability') or purchase.views_availability is None: - purchase.views_availability = domain.PostViewsAvailability.UNKNOWN + async def create_placement(self, placement: domain.Placement) -> domain.Placement: + if not placement.id: + placement.id = uuid.uuid4() + if not hasattr(placement, 'created_at') or placement.created_at is None: + placement.created_at = datetime.datetime.now(datetime.UTC) + if not hasattr(placement, 'updated_at') or placement.updated_at is None: + placement.updated_at = datetime.datetime.now(datetime.UTC) + if not hasattr(placement, 'subscriptions_count') or placement.subscriptions_count is None: + placement.subscriptions_count = 0 + if not hasattr(placement, 'status') or placement.status is None: + placement.status = domain.PlacementStatus.ACTIVE + if not hasattr(placement, 'views_availability') or placement.views_availability is None: + placement.views_availability = domain.PostViewsAvailability.UNKNOWN # Загружаем связанные объекты - target_ch = self.target_channels.get(purchase.target_channel_id) + target_ch = self.target_channels.get(placement.target_channel_id) if target_ch: - purchase.target_channel = target_ch - ext_ch = self.external_channels.get(purchase.external_channel_id) + placement.target_channel = target_ch + ext_ch = self.external_channels.get(placement.external_channel_id) if ext_ch: - purchase.external_channel = ext_ch - creative = self.creatives.get(purchase.creative_id) + placement.external_channel = ext_ch + creative = self.creatives.get(placement.creative_id) if creative: - purchase.creative = creative - self.purchases[purchase.id] = purchase - return purchase + placement.creative = creative + self.placements[placement.id] = placement + return placement - async def get_purchase(self, user_id: uuid.UUID, purchase_id: uuid.UUID) -> domain.Purchase | None: - purchase = self.purchases.get(purchase_id) - if purchase and purchase.user_id == user_id: - return purchase + async def get_placement(self, user_id: uuid.UUID, placement_id: uuid.UUID) -> domain.Placement | None: + placement = self.placements.get(placement_id) + if placement and placement.user_id == user_id: + return placement return None - async def update_purchase(self, purchase: domain.Purchase) -> domain.Purchase: - self.purchases[purchase.id] = purchase - return purchase + async def update_placement(self, placement: domain.Placement) -> domain.Placement: + self.placements[placement.id] = placement + return placement - async def get_user_purchases( + async def get_user_placements( self, user_id: uuid.UUID, *, @@ -240,8 +242,8 @@ class MockDatabase: external_channel_id: uuid.UUID | None = None, creative_id: uuid.UUID | None = None, include_archived: bool = False, - ) -> list[domain.Purchase]: - result = [p for p in self.purchases.values() if p.user_id == user_id] + ) -> list[domain.Placement]: + result = [p for p in self.placements.values() if p.user_id == user_id] if target_channel_id: result = [p for p in result if p.target_channel_id == target_channel_id] if external_channel_id: @@ -249,24 +251,47 @@ class MockDatabase: if creative_id: result = [p for p in result if p.creative_id == creative_id] if not include_archived: - result = [p for p in result if p.status == domain.PurchaseStatus.ACTIVE] + result = [p for p in result if p.status == domain.PlacementStatus.ACTIVE] return sorted(result, key=lambda p: p.placement_date, reverse=True) - async def delete_purchase(self, purchase_id: uuid.UUID) -> None: - self.purchases.pop(purchase_id, None) + async def delete_placement(self, placement_id: uuid.UUID) -> None: + self.placements.pop(placement_id, None) async def check_creative_in_use(self, creative_id: uuid.UUID) -> bool: - for purchase in self.purchases.values(): - if purchase.creative_id == creative_id and purchase.status == domain.PurchaseStatus.ACTIVE: + for placement in self.placements.values(): + if placement.creative_id == creative_id and placement.status == domain.PlacementStatus.ACTIVE: return True return False - async def get_purchase_by_invite_link(self, invite_link: str) -> domain.Purchase | None: - for purchase in self.purchases.values(): - if purchase.invite_link == invite_link: - return purchase + async def get_placement_by_invite_link(self, invite_link: str) -> domain.Placement | None: + for placement in self.placements.values(): + if placement.invite_link == invite_link: + return placement return None + async def get_subscriber(self, telegram_id: int) -> domain.Subscriber | None: + return self.subscribers_by_telegram_id.get(telegram_id) + + async def create_subscriber(self, subscriber: domain.Subscriber) -> domain.Subscriber: + if not subscriber.id: + subscriber.id = uuid.uuid4() + if not hasattr(subscriber, 'created_at') or subscriber.created_at is None: + subscriber.created_at = datetime.datetime.now(datetime.UTC) + if not hasattr(subscriber, 'updated_at') or subscriber.updated_at is None: + subscriber.updated_at = datetime.datetime.now(datetime.UTC) + self.subscribers[subscriber.id] = subscriber + self.subscribers_by_telegram_id[subscriber.telegram_id] = subscriber + return subscriber + + async def upsert_subscriber(self, subscriber: domain.Subscriber) -> domain.Subscriber: + existing = await self.get_subscriber(subscriber.telegram_id) + if existing: + existing.username = subscriber.username + existing.first_name = subscriber.first_name + existing.last_name = subscriber.last_name + return existing + return await self.create_subscriber(subscriber) + async def create_subscription(self, subscription: domain.Subscription) -> domain.Subscription: if not subscription.id: subscription.id = uuid.uuid4() @@ -274,47 +299,52 @@ class MockDatabase: subscription.created_at = datetime.datetime.now(datetime.UTC) if not hasattr(subscription, 'updated_at') or subscription.updated_at is None: subscription.updated_at = datetime.datetime.now(datetime.UTC) - # Загружаем связанный объект - purchase = self.purchases.get(subscription.purchase_id) - if purchase: - subscription.purchase = purchase + # Загружаем связанные объекты + placement = self.placements.get(subscription.placement_id) + if placement: + subscription.placement = placement + subscriber = self.subscribers.get(subscription.subscriber_id) + if subscriber: + subscription.subscriber = subscriber self.subscriptions[subscription.id] = subscription return subscription - async def get_subscription_by_user_and_purchase( - self, user_telegram_id: int, purchase_id: uuid.UUID + async def get_subscription_by_subscriber_and_placement( + self, subscriber_id: uuid.UUID, placement_id: uuid.UUID ) -> domain.Subscription | None: for subscription in self.subscriptions.values(): - if subscription.user_telegram_id == user_telegram_id and subscription.purchase_id == purchase_id: + if subscription.subscriber_id == subscriber_id and subscription.placement_id == placement_id: return subscription return None - async def create_views_snapshot(self, snapshot: domain.ViewsSnapshot) -> domain.ViewsSnapshot: - if not snapshot.id: - snapshot.id = uuid.uuid4() - if not hasattr(snapshot, 'created_at') or snapshot.created_at is None: - snapshot.created_at = datetime.datetime.now(datetime.UTC) - if not hasattr(snapshot, 'updated_at') or snapshot.updated_at is None: - snapshot.updated_at = datetime.datetime.now(datetime.UTC) - purchase = self.purchases.get(snapshot.purchase_id) - if purchase: - snapshot.purchase = purchase - self.views_snapshots[snapshot.id] = snapshot - return snapshot + async def create_placement_views_history( + self, history: domain.PlacementViewsHistory + ) -> domain.PlacementViewsHistory: + if not history.id: + history.id = uuid.uuid4() + if not hasattr(history, 'created_at') or history.created_at is None: + history.created_at = datetime.datetime.now(datetime.UTC) + if not hasattr(history, 'updated_at') or history.updated_at is None: + history.updated_at = datetime.datetime.now(datetime.UTC) + placement = self.placements.get(history.placement_id) + if placement: + history.placement = placement + self.placement_views_histories[history.id] = history + return history async def get_views_history( self, - purchase_id: uuid.UUID, + placement_id: uuid.UUID, *, from_date: datetime.datetime | None = None, to_date: datetime.datetime | None = None, - ) -> list[domain.ViewsSnapshot]: - result = [s for s in self.views_snapshots.values() if s.purchase_id == purchase_id] + ) -> list[domain.PlacementViewsHistory]: + result = [h for h in self.placement_views_histories.values() if h.placement_id == placement_id] if from_date: - result = [s for s in result if s.fetched_at >= from_date] + result = [h for h in result if h.fetched_at >= from_date] if to_date: - result = [s for s in result if s.fetched_at <= to_date] - return sorted(result, key=lambda s: s.fetched_at) + result = [h for h in result if h.fetched_at <= to_date] + return sorted(result, key=lambda h: h.fetched_at) class MockTelegramWriter: diff --git a/tests/test_creatives.py b/tests/test_creatives.py index 66e188e..06c7a8e 100644 --- a/tests/test_creatives.py +++ b/tests/test_creatives.py @@ -34,7 +34,7 @@ async def test_create_creative_success(usecases: usecase.Usecase, mock_database: assert result.text == 'Приглашаю подписаться на канал: {link}' assert result.target_channel_id == target_channel.id assert result.status == domain.CreativeStatus.ACTIVE - assert result.purchases_count == 0 + assert result.placements_count == 0 @pytest.mark.asyncio diff --git a/tests/test_purchases.py b/tests/test_placements.py similarity index 80% rename from tests/test_purchases.py rename to tests/test_placements.py index 104d012..9976d2c 100644 --- a/tests/test_purchases.py +++ b/tests/test_placements.py @@ -10,7 +10,7 @@ from tests.conftest import MockDatabase @pytest.mark.asyncio -async def test_create_purchase_with_public_invite_link(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: +async def test_create_placement_with_public_invite_link(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: """Создание закупа с публичной пригласительной ссылкой.""" user = domain.User(telegram_id=123456, username='testuser') await mock_database.create_user(user) @@ -44,7 +44,7 @@ async def test_create_purchase_with_public_invite_link(usecases: usecase.Usecase await mock_database.create_creative(creative) placement_date = datetime.datetime.now(datetime.UTC) - input_data = dto.CreatePurchaseInput( + input_data = dto.CreatePlacementInput( target_channel_id=target_channel.id, external_channel_id=external_channel.id, creative_id=creative.id, @@ -55,7 +55,7 @@ async def test_create_purchase_with_public_invite_link(usecases: usecase.Usecase invite_link_type=domain.InviteLinkType.PUBLIC, ) - result = await usecases.create_purchase(input_data, user.id) + result = await usecases.create_placement(input_data, user.id) assert result.target_channel_id == target_channel.id assert result.external_channel_id == external_channel.id @@ -64,17 +64,17 @@ async def test_create_purchase_with_public_invite_link(usecases: usecase.Usecase assert result.comment == 'Утреннее размещение' assert result.invite_link_type == domain.InviteLinkType.PUBLIC assert result.invite_link.startswith('https://t.me/+public_link_') - assert result.status == domain.PurchaseStatus.ACTIVE + assert result.status == domain.PlacementStatus.ACTIVE assert result.subscriptions_count == 0 # Проверяем, что счётчик закупов у креатива увеличился updated_creative = await mock_database.get_creative(user.id, creative.id) assert updated_creative is not None - assert updated_creative.purchases_count == 1 + assert updated_creative.placements_count == 1 @pytest.mark.asyncio -async def test_create_purchase_with_approval_invite_link( +async def test_create_placement_with_approval_invite_link( usecases: usecase.Usecase, mock_database: MockDatabase ) -> None: """Создание закупа с пригласительной ссылкой, требующей одобрения.""" @@ -110,7 +110,7 @@ async def test_create_purchase_with_approval_invite_link( await mock_database.create_creative(creative) placement_date = datetime.datetime.now(datetime.UTC) - input_data = dto.CreatePurchaseInput( + input_data = dto.CreatePlacementInput( target_channel_id=target_channel.id, external_channel_id=external_channel.id, creative_id=creative.id, @@ -118,14 +118,14 @@ async def test_create_purchase_with_approval_invite_link( invite_link_type=domain.InviteLinkType.APPROVAL, ) - result = await usecases.create_purchase(input_data, user.id) + result = await usecases.create_placement(input_data, user.id) assert result.invite_link_type == domain.InviteLinkType.APPROVAL assert 'approval_link_' in result.invite_link @pytest.mark.asyncio -async def test_create_purchase_invalid_target_channel(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: +async def test_create_placement_invalid_target_channel(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: """Создание закупа для несуществующего целевого канала вызывает ошибку.""" user = domain.User(telegram_id=123456, username='testuser') await mock_database.create_user(user) @@ -136,7 +136,7 @@ async def test_create_purchase_invalid_target_channel(usecases: usecase.Usecase, fake_external_id = uuid.uuid4() fake_creative_id = uuid.uuid4() - input_data = dto.CreatePurchaseInput( + input_data = dto.CreatePlacementInput( target_channel_id=fake_target_id, external_channel_id=fake_external_id, creative_id=fake_creative_id, @@ -144,13 +144,13 @@ async def test_create_purchase_invalid_target_channel(usecases: usecase.Usecase, ) with pytest.raises(HTTPException) as exc_info: - await usecases.create_purchase(input_data, user.id) + await usecases.create_placement(input_data, user.id) assert exc_info.value.status_code == 404 assert 'Target channel' in str(exc_info.value.detail) @pytest.mark.asyncio -async def test_get_user_purchases(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: +async def test_get_user_placements(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: """Получение списка закупов пользователя.""" user = domain.User(telegram_id=123456, username='testuser') await mock_database.create_user(user) @@ -184,7 +184,7 @@ async def test_get_user_purchases(usecases: usecase.Usecase, mock_database: Mock await mock_database.create_creative(creative) # Создаём два закупа - purchase1 = domain.Purchase( + placement1 = domain.Placement( target_channel_id=target_channel.id, external_channel_id=external_channel.id, creative_id=creative.id, @@ -193,9 +193,9 @@ async def test_get_user_purchases(usecases: usecase.Usecase, mock_database: Mock cost=3000.0, invite_link='https://t.me/+link1', invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PurchaseStatus.ACTIVE, + status=domain.PlacementStatus.ACTIVE, ) - purchase2 = domain.Purchase( + placement2 = domain.Placement( target_channel_id=target_channel.id, external_channel_id=external_channel.id, creative_id=creative.id, @@ -204,20 +204,22 @@ async def test_get_user_purchases(usecases: usecase.Usecase, mock_database: Mock cost=4000.0, invite_link='https://t.me/+link2', invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PurchaseStatus.ACTIVE, + status=domain.PlacementStatus.ACTIVE, ) - await mock_database.create_purchase(purchase1) - await mock_database.create_purchase(purchase2) + await mock_database.create_placement(placement1) + await mock_database.create_placement(placement2) - result = await usecases.get_purchases(dto.GetPurchasesInput(user_id=user.id)) + result = await usecases.get_placements(dto.GetPlacementsInput(user_id=user.id)) - assert len(result.purchases) == 2 + assert len(result.placements) == 2 # Проверяем сортировку по дате размещения (новые первые) - assert result.purchases[0].id == purchase1.id + assert result.placements[0].id == placement1.id @pytest.mark.asyncio -async def test_get_purchases_filtered_by_target_channel(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: +async def test_get_placements_filtered_by_target_channel( + usecases: usecase.Usecase, mock_database: MockDatabase +) -> None: """Получение закупов, отфильтрованных по целевому каналу.""" user = domain.User(telegram_id=123456, username='testuser') await mock_database.create_user(user) @@ -266,7 +268,7 @@ async def test_get_purchases_filtered_by_target_channel(usecases: usecase.Usecas await mock_database.create_creative(creative1) await mock_database.create_creative(creative2) - purchase1 = domain.Purchase( + placement1 = domain.Placement( target_channel_id=target1.id, external_channel_id=external_channel.id, creative_id=creative1.id, @@ -274,9 +276,9 @@ async def test_get_purchases_filtered_by_target_channel(usecases: usecase.Usecas placement_date=datetime.datetime.now(datetime.UTC), invite_link='https://t.me/+link1', invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PurchaseStatus.ACTIVE, + status=domain.PlacementStatus.ACTIVE, ) - purchase2 = domain.Purchase( + placement2 = domain.Placement( target_channel_id=target2.id, external_channel_id=external_channel.id, creative_id=creative2.id, @@ -284,19 +286,19 @@ async def test_get_purchases_filtered_by_target_channel(usecases: usecase.Usecas placement_date=datetime.datetime.now(datetime.UTC), invite_link='https://t.me/+link2', invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PurchaseStatus.ACTIVE, + status=domain.PlacementStatus.ACTIVE, ) - await mock_database.create_purchase(purchase1) - await mock_database.create_purchase(purchase2) + await mock_database.create_placement(placement1) + await mock_database.create_placement(placement2) - result = await usecases.get_purchases(dto.GetPurchasesInput(user_id=user.id, target_channel_id=target1.id)) + result = await usecases.get_placements(dto.GetPlacementsInput(user_id=user.id, target_channel_id=target1.id)) - assert len(result.purchases) == 1 - assert result.purchases[0].target_channel_id == target1.id + assert len(result.placements) == 1 + assert result.placements[0].target_channel_id == target1.id @pytest.mark.asyncio -async def test_get_purchases_filtered_by_creative(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: +async def test_get_placements_filtered_by_creative(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: """Получение закупов, отфильтрованных по креативу - для оценки эффективности креатива.""" user = domain.User(telegram_id=123456, username='testuser') await mock_database.create_user(user) @@ -338,7 +340,7 @@ async def test_get_purchases_filtered_by_creative(usecases: usecase.Usecase, moc await mock_database.create_creative(creative2) # Создаём закупы с разными креативами - purchase1 = domain.Purchase( + placement1 = domain.Placement( target_channel_id=target_channel.id, external_channel_id=external_channel.id, creative_id=creative1.id, @@ -346,9 +348,9 @@ async def test_get_purchases_filtered_by_creative(usecases: usecase.Usecase, moc placement_date=datetime.datetime.now(datetime.UTC), invite_link='https://t.me/+link1', invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PurchaseStatus.ACTIVE, + status=domain.PlacementStatus.ACTIVE, ) - purchase2 = domain.Purchase( + placement2 = domain.Placement( target_channel_id=target_channel.id, external_channel_id=external_channel.id, creative_id=creative2.id, @@ -356,19 +358,19 @@ async def test_get_purchases_filtered_by_creative(usecases: usecase.Usecase, moc placement_date=datetime.datetime.now(datetime.UTC), invite_link='https://t.me/+link2', invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PurchaseStatus.ACTIVE, + status=domain.PlacementStatus.ACTIVE, ) - await mock_database.create_purchase(purchase1) - await mock_database.create_purchase(purchase2) + await mock_database.create_placement(placement1) + await mock_database.create_placement(placement2) - result = await usecases.get_purchases(dto.GetPurchasesInput(user_id=user.id, creative_id=creative1.id)) + result = await usecases.get_placements(dto.GetPlacementsInput(user_id=user.id, creative_id=creative1.id)) - assert len(result.purchases) == 1 - assert result.purchases[0].creative_id == creative1.id + assert len(result.placements) == 1 + assert result.placements[0].creative_id == creative1.id @pytest.mark.asyncio -async def test_archive_purchase(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: +async def test_archive_placement(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: """Архивирование закупа - убирает его из активной статистики.""" user = domain.User(telegram_id=123456, username='testuser') await mock_database.create_user(user) @@ -401,7 +403,7 @@ async def test_archive_purchase(usecases: usecase.Usecase, mock_database: MockDa ) await mock_database.create_creative(creative) - purchase = domain.Purchase( + placement = domain.Placement( target_channel_id=target_channel.id, external_channel_id=external_channel.id, creative_id=creative.id, @@ -409,27 +411,27 @@ async def test_archive_purchase(usecases: usecase.Usecase, mock_database: MockDa placement_date=datetime.datetime.now(datetime.UTC), invite_link='https://t.me/+link1', invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PurchaseStatus.ACTIVE, + status=domain.PlacementStatus.ACTIVE, ) - await mock_database.create_purchase(purchase) + await mock_database.create_placement(placement) - result = await usecases.archive_purchase(purchase.id, user.id) + result = await usecases.archive_placement(placement.id, user.id) - assert result.status == domain.PurchaseStatus.ARCHIVED + assert result.status == domain.PlacementStatus.ARCHIVED # Проверяем, что архивированный закуп не возвращается по умолчанию - purchases_list = await usecases.get_purchases(dto.GetPurchasesInput(user_id=user.id)) - assert len(purchases_list.purchases) == 0 + placements_list = await usecases.get_placements(dto.GetPlacementsInput(user_id=user.id)) + assert len(placements_list.placements) == 0 # Проверяем, что архивированный закуп возвращается с флагом include_archived - purchases_with_archived = await usecases.get_purchases( - dto.GetPurchasesInput(user_id=user.id, include_archived=True) + placements_with_archived = await usecases.get_placements( + dto.GetPlacementsInput(user_id=user.id, include_archived=True) ) - assert len(purchases_with_archived.purchases) == 1 + assert len(placements_with_archived.placements) == 1 @pytest.mark.asyncio -async def test_update_purchase(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: +async def test_update_placement(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: """Редактирование закупа - обновление стоимости, комментария, даты.""" user = domain.User(telegram_id=123456, username='testuser') await mock_database.create_user(user) @@ -463,7 +465,7 @@ async def test_update_purchase(usecases: usecase.Usecase, mock_database: MockDat await mock_database.create_creative(creative) old_date = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=1) - purchase = domain.Purchase( + placement = domain.Placement( target_channel_id=target_channel.id, external_channel_id=external_channel.id, creative_id=creative.id, @@ -473,18 +475,18 @@ async def test_update_purchase(usecases: usecase.Usecase, mock_database: MockDat comment='Старый комментарий', invite_link='https://t.me/+link1', invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PurchaseStatus.ACTIVE, + status=domain.PlacementStatus.ACTIVE, ) - await mock_database.create_purchase(purchase) + await mock_database.create_placement(placement) new_date = datetime.datetime.now(datetime.UTC) - update_input = dto.UpdatePurchaseInput( + update_input = dto.UpdatePlacementInput( placement_date=new_date, cost=3500.0, comment='Обновлённый комментарий', ad_post_url='https://t.me/externalchannel/456', ) - result = await usecases.update_purchase(purchase.id, update_input, user.id) + result = await usecases.update_placement(placement.id, update_input, user.id) assert result.placement_date == new_date assert result.cost == 3500.0 @@ -493,7 +495,7 @@ async def test_update_purchase(usecases: usecase.Usecase, mock_database: MockDat @pytest.mark.asyncio -async def test_delete_purchase(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: +async def test_delete_placement(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: """Удаление закупа - уменьшает счётчик закупов у креатива.""" user = domain.User(telegram_id=123456, username='testuser') await mock_database.create_user(user) @@ -523,11 +525,11 @@ async def test_delete_purchase(usecases: usecase.Usecase, mock_database: MockDat target_channel_id=target_channel.id, user_id=user.id, status=domain.CreativeStatus.ACTIVE, - purchases_count=1, + placements_count=1, ) await mock_database.create_creative(creative) - purchase = domain.Purchase( + placement = domain.Placement( target_channel_id=target_channel.id, external_channel_id=external_channel.id, creative_id=creative.id, @@ -535,24 +537,24 @@ async def test_delete_purchase(usecases: usecase.Usecase, mock_database: MockDat placement_date=datetime.datetime.now(datetime.UTC), invite_link='https://t.me/+link1', invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PurchaseStatus.ACTIVE, + status=domain.PlacementStatus.ACTIVE, ) - await mock_database.create_purchase(purchase) + await mock_database.create_placement(placement) - await usecases.delete_purchase(dto.DeletePurchaseInput(purchase_id=purchase.id, user_id=user.id)) + await usecases.delete_placement(dto.DeletePlacementInput(placement_id=placement.id, user_id=user.id)) # Проверяем, что закуп удалён - purchases_list = await usecases.get_purchases(dto.GetPurchasesInput(user_id=user.id, include_archived=True)) - assert len(purchases_list.purchases) == 0 + placements_list = await usecases.get_placements(dto.GetPlacementsInput(user_id=user.id, include_archived=True)) + assert len(placements_list.placements) == 0 # Проверяем, что счётчик закупов у креатива уменьшился updated_creative = await mock_database.get_creative(user.id, creative.id) assert updated_creative is not None - assert updated_creative.purchases_count == 0 + assert updated_creative.placements_count == 0 @pytest.mark.asyncio -async def test_multiple_purchases_same_creative(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: +async def test_multiple_placements_same_creative(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: """Создание нескольких закупов с одним креативом - для A/B тестирования в разных каналах.""" user = domain.User(telegram_id=123456, username='testuser') await mock_database.create_user(user) @@ -595,14 +597,14 @@ async def test_multiple_purchases_same_creative(usecases: usecase.Usecase, mock_ await mock_database.create_creative(creative) # Создаём два закупа с одним креативом в разных внешних каналах - input1 = dto.CreatePurchaseInput( + input1 = dto.CreatePlacementInput( target_channel_id=target_channel.id, external_channel_id=external1.id, creative_id=creative.id, placement_date=datetime.datetime.now(datetime.UTC), cost=2000.0, ) - input2 = dto.CreatePurchaseInput( + input2 = dto.CreatePlacementInput( target_channel_id=target_channel.id, external_channel_id=external2.id, creative_id=creative.id, @@ -610,8 +612,8 @@ async def test_multiple_purchases_same_creative(usecases: usecase.Usecase, mock_ cost=4000.0, ) - result1 = await usecases.create_purchase(input1, user.id) - result2 = await usecases.create_purchase(input2, user.id) + result1 = await usecases.create_placement(input1, user.id) + result2 = await usecases.create_placement(input2, user.id) # Проверяем, что созданы два разных закупа assert result1.id != result2.id @@ -620,10 +622,10 @@ async def test_multiple_purchases_same_creative(usecases: usecase.Usecase, mock_ # Проверяем, что счётчик креатива увеличился на 2 updated_creative = await mock_database.get_creative(user.id, creative.id) assert updated_creative is not None - assert updated_creative.purchases_count == 2 + assert updated_creative.placements_count == 2 # Проверяем фильтрацию по креативу - purchases_for_creative = await usecases.get_purchases( - dto.GetPurchasesInput(user_id=user.id, creative_id=creative.id) + placements_for_creative = await usecases.get_placements( + dto.GetPlacementsInput(user_id=user.id, creative_id=creative.id) ) - assert len(purchases_for_creative.purchases) == 2 + assert len(placements_for_creative.placements) == 2 diff --git a/tests/test_subscriptions.py b/tests/test_subscriptions.py index 7417f7f..d3ab6d2 100644 --- a/tests/test_subscriptions.py +++ b/tests/test_subscriptions.py @@ -43,7 +43,7 @@ async def test_handle_subscription_success(usecases: usecase.Usecase, mock_datab await mock_database.create_creative(creative) invite_link = 'https://t.me/+unique_link_123' - purchase = domain.Purchase( + placement = domain.Placement( target_channel_id=target_channel.id, external_channel_id=external_channel.id, creative_id=creative.id, @@ -51,10 +51,10 @@ async def test_handle_subscription_success(usecases: usecase.Usecase, mock_datab placement_date=datetime.datetime.now(datetime.UTC), invite_link=invite_link, invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PurchaseStatus.ACTIVE, + status=domain.PlacementStatus.ACTIVE, subscriptions_count=0, ) - await mock_database.create_purchase(purchase) + await mock_database.create_placement(placement) # Симулируем подписку пользователя subscriber_id = 999888777 @@ -66,16 +66,20 @@ async def test_handle_subscription_success(usecases: usecase.Usecase, mock_datab ) # Проверяем, что подписка создана - subscription = await mock_database.get_subscription_by_user_and_purchase(subscriber_id, purchase.id) + subscriber = await mock_database.get_subscriber(subscriber_id) + assert subscriber is not None + assert subscriber.telegram_id == subscriber_id + assert subscriber.username == subscriber_username + + subscription = await mock_database.get_subscription_by_subscriber_and_placement(subscriber.id, placement.id) assert subscription is not None - assert subscription.user_telegram_id == subscriber_id - assert subscription.username == subscriber_username + assert subscription.subscriber_id == subscriber.id assert subscription.invite_link == invite_link # Проверяем, что счётчик подписок у закупа увеличился - updated_purchase = await mock_database.get_purchase(user.id, purchase.id) - assert updated_purchase is not None - assert updated_purchase.subscriptions_count == 1 + updated_placement = await mock_database.get_placement(user.id, placement.id) + assert updated_placement is not None + assert updated_placement.subscriptions_count == 1 @pytest.mark.asyncio @@ -113,7 +117,7 @@ async def test_handle_subscription_duplicate_ignored(usecases: usecase.Usecase, await mock_database.create_creative(creative) invite_link = 'https://t.me/+unique_link_456' - purchase = domain.Purchase( + placement = domain.Placement( target_channel_id=target_channel.id, external_channel_id=external_channel.id, creative_id=creative.id, @@ -121,10 +125,10 @@ async def test_handle_subscription_duplicate_ignored(usecases: usecase.Usecase, placement_date=datetime.datetime.now(datetime.UTC), invite_link=invite_link, invite_link_type=domain.InviteLinkType.APPROVAL, - status=domain.PurchaseStatus.ACTIVE, + status=domain.PlacementStatus.ACTIVE, subscriptions_count=0, ) - await mock_database.create_purchase(purchase) + await mock_database.create_placement(placement) subscriber_id = 888777666 subscriber_username = 'duplicate_user' @@ -137,9 +141,9 @@ async def test_handle_subscription_duplicate_ignored(usecases: usecase.Usecase, ) # Проверяем счётчик - updated_purchase = await mock_database.get_purchase(user.id, purchase.id) - assert updated_purchase is not None - assert updated_purchase.subscriptions_count == 1 + updated_placement = await mock_database.get_placement(user.id, placement.id) + assert updated_placement is not None + assert updated_placement.subscriptions_count == 1 # Повторная попытка подписки того же пользователя await usecases.handle_subscription( @@ -149,12 +153,14 @@ async def test_handle_subscription_duplicate_ignored(usecases: usecase.Usecase, ) # Проверяем, что счётчик не увеличился - updated_purchase_again = await mock_database.get_purchase(user.id, purchase.id) - assert updated_purchase_again is not None - assert updated_purchase_again.subscriptions_count == 1 # Остался 1, не увеличился + updated_placement_again = await mock_database.get_placement(user.id, placement.id) + assert updated_placement_again is not None + assert updated_placement_again.subscriptions_count == 1 # Остался 1, не увеличился - # Проверяем, что в БД только одна подписка этого пользователя - all_subscriptions = [s for s in mock_database.subscriptions.values() if s.user_telegram_id == subscriber_id] + # Проверяем, что в БД только одна подписка этого подписчика + subscriber = await mock_database.get_subscriber(subscriber_id) + assert subscriber is not None + all_subscriptions = [s for s in mock_database.subscriptions.values() if s.subscriber_id == subscriber.id] assert len(all_subscriptions) == 1 @@ -175,7 +181,7 @@ async def test_handle_subscription_invalid_invite_link(usecases: usecase.Usecase @pytest.mark.asyncio -async def test_multiple_users_subscription_same_purchase( +async def test_multiple_users_subscription_same_placement( usecases: usecase.Usecase, mock_database: MockDatabase ) -> None: """Несколько пользователей подписываются через один закуп - каждый учитывается.""" @@ -211,7 +217,7 @@ async def test_multiple_users_subscription_same_purchase( await mock_database.create_creative(creative) invite_link = 'https://t.me/+popular_link_789' - purchase = domain.Purchase( + placement = domain.Placement( target_channel_id=target_channel.id, external_channel_id=external_channel.id, creative_id=creative.id, @@ -219,10 +225,10 @@ async def test_multiple_users_subscription_same_purchase( placement_date=datetime.datetime.now(datetime.UTC), invite_link=invite_link, invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PurchaseStatus.ACTIVE, + status=domain.PlacementStatus.ACTIVE, subscriptions_count=0, ) - await mock_database.create_purchase(purchase) + await mock_database.create_placement(placement) # Подписываются три разных пользователя users_data = [ @@ -239,17 +245,17 @@ async def test_multiple_users_subscription_same_purchase( ) # Проверяем, что создано 3 подписки - purchase_subscriptions = [s for s in mock_database.subscriptions.values() if s.purchase_id == purchase.id] - assert len(purchase_subscriptions) == 3 + placement_subscriptions = [s for s in mock_database.subscriptions.values() if s.placement_id == placement.id] + assert len(placement_subscriptions) == 3 # Проверяем, что счётчик правильный - updated_purchase = await mock_database.get_purchase(user.id, purchase.id) - assert updated_purchase is not None - assert updated_purchase.subscriptions_count == 3 + updated_placement = await mock_database.get_placement(user.id, placement.id) + assert updated_placement is not None + assert updated_placement.subscriptions_count == 3 @pytest.mark.asyncio -async def test_subscription_tracking_across_different_purchases( +async def test_subscription_tracking_across_different_placements( usecases: usecase.Usecase, mock_database: MockDatabase ) -> None: """Один пользователь может подписаться через разные закупы - каждая подписка учитывается отдельно.""" @@ -285,7 +291,7 @@ async def test_subscription_tracking_across_different_purchases( await mock_database.create_creative(creative) # Создаём два разных закупа с разными invite_link - purchase1 = domain.Purchase( + placement1 = domain.Placement( target_channel_id=target_channel.id, external_channel_id=external_channel.id, creative_id=creative.id, @@ -293,10 +299,10 @@ async def test_subscription_tracking_across_different_purchases( placement_date=datetime.datetime.now(datetime.UTC), invite_link='https://t.me/+link_a', invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PurchaseStatus.ACTIVE, + status=domain.PlacementStatus.ACTIVE, subscriptions_count=0, ) - purchase2 = domain.Purchase( + placement2 = domain.Placement( target_channel_id=target_channel.id, external_channel_id=external_channel.id, creative_id=creative.id, @@ -304,11 +310,11 @@ async def test_subscription_tracking_across_different_purchases( placement_date=datetime.datetime.now(datetime.UTC), invite_link='https://t.me/+link_b', invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PurchaseStatus.ACTIVE, + status=domain.PlacementStatus.ACTIVE, subscriptions_count=0, ) - await mock_database.create_purchase(purchase1) - await mock_database.create_purchase(purchase2) + await mock_database.create_placement(placement1) + await mock_database.create_placement(placement2) subscriber_id = 555444333 @@ -316,7 +322,7 @@ async def test_subscription_tracking_across_different_purchases( await usecases.handle_subscription( user_telegram_id=subscriber_id, username='multi_subscriber', - invite_link=purchase1.invite_link, + invite_link=placement1.invite_link, ) # Тот же пользователь "подписывается" через второй закуп @@ -324,19 +330,21 @@ async def test_subscription_tracking_across_different_purchases( await usecases.handle_subscription( user_telegram_id=subscriber_id, username='multi_subscriber', - invite_link=purchase2.invite_link, + invite_link=placement2.invite_link, ) # Проверяем, что у каждого закупа свой счётчик - updated_purchase1 = await mock_database.get_purchase(user.id, purchase1.id) - updated_purchase2 = await mock_database.get_purchase(user.id, purchase2.id) + updated_placement1 = await mock_database.get_placement(user.id, placement1.id) + updated_placement2 = await mock_database.get_placement(user.id, placement2.id) - assert updated_purchase1 is not None - assert updated_purchase1.subscriptions_count == 1 + assert updated_placement1 is not None + assert updated_placement1.subscriptions_count == 1 - assert updated_purchase2 is not None - assert updated_purchase2.subscriptions_count == 1 + assert updated_placement2 is not None + assert updated_placement2.subscriptions_count == 1 # Проверяем, что создано 2 подписки - user_subscriptions = [s for s in mock_database.subscriptions.values() if s.user_telegram_id == subscriber_id] + subscriber = await mock_database.get_subscriber(subscriber_id) + assert subscriber is not None + user_subscriptions = [s for s in mock_database.subscriptions.values() if s.subscriber_id == subscriber.id] assert len(user_subscriptions) == 2 diff --git a/tests/test_views.py b/tests/test_views.py index 4fdb519..8c31ede 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -42,7 +42,7 @@ async def test_fetch_views_no_ad_post_url(usecases: usecase.Usecase, mock_databa ) await mock_database.create_creative(creative) - purchase = domain.Purchase( + placement = domain.Placement( target_channel_id=target_channel.id, external_channel_id=external_channel.id, creative_id=creative.id, @@ -50,13 +50,13 @@ async def test_fetch_views_no_ad_post_url(usecases: usecase.Usecase, mock_databa placement_date=datetime.datetime.now(datetime.UTC), invite_link='https://t.me/+link1', invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PurchaseStatus.ACTIVE, + status=domain.PlacementStatus.ACTIVE, ad_post_url=None, # Нет ссылки на пост ) - await mock_database.create_purchase(purchase) + await mock_database.create_placement(placement) input_data = dto.FetchViewsInputManually( - purchase_id=purchase.id, + placement_id=placement.id, user_id=user.id, ) @@ -101,7 +101,7 @@ async def test_fetch_views_api_unavailable(usecases: usecase.Usecase, mock_datab ) await mock_database.create_creative(creative) - purchase = domain.Purchase( + placement = domain.Placement( target_channel_id=target_channel.id, external_channel_id=external_channel.id, creative_id=creative.id, @@ -109,13 +109,13 @@ async def test_fetch_views_api_unavailable(usecases: usecase.Usecase, mock_datab placement_date=datetime.datetime.now(datetime.UTC), invite_link='https://t.me/+link2', invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PurchaseStatus.ACTIVE, + status=domain.PlacementStatus.ACTIVE, ad_post_url='https://t.me/externalchannel/123', ) - await mock_database.create_purchase(purchase) + await mock_database.create_placement(placement) input_data = dto.FetchViewsInputManually( - purchase_id=purchase.id, + placement_id=placement.id, user_id=user.id, ) @@ -126,11 +126,11 @@ async def test_fetch_views_api_unavailable(usecases: usecase.Usecase, mock_datab assert result.views_availability == domain.PostViewsAvailability.UNAVAILABLE assert 'Failed to fetch views' in result.error_message - # Проверяем, что purchase обновлён - updated_purchase = await mock_database.get_purchase(user.id, purchase.id) - assert updated_purchase is not None - assert updated_purchase.views_availability == domain.PostViewsAvailability.UNAVAILABLE - assert updated_purchase.last_views_fetch_at is not None + # Проверяем, что placement обновлён + updated_placement = await mock_database.get_placement(user.id, placement.id) + assert updated_placement is not None + assert updated_placement.views_availability == domain.PostViewsAvailability.UNAVAILABLE + assert updated_placement.last_views_fetch_at is not None @pytest.mark.asyncio @@ -167,7 +167,7 @@ async def test_update_views_manually(usecases: usecase.Usecase, mock_database: M ) await mock_database.create_creative(creative) - purchase = domain.Purchase( + placement = domain.Placement( target_channel_id=target_channel.id, external_channel_id=external_channel.id, creative_id=creative.id, @@ -175,13 +175,13 @@ async def test_update_views_manually(usecases: usecase.Usecase, mock_database: M placement_date=datetime.datetime.now(datetime.UTC), invite_link='https://t.me/+link3', invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PurchaseStatus.ACTIVE, + status=domain.PlacementStatus.ACTIVE, ad_post_url='https://t.me/externalchannel/456', ) - await mock_database.create_purchase(purchase) + await mock_database.create_placement(placement) input_data = dto.UpdateViewsManuallyInput( - purchase_id=purchase.id, + placement_id=placement.id, user_id=user.id, views_count=15000, ) @@ -193,16 +193,16 @@ async def test_update_views_manually(usecases: usecase.Usecase, mock_database: M assert result.views_availability == domain.PostViewsAvailability.MANUAL assert result.last_views_fetch_at is not None - # Проверяем, что создан снимок - snapshots = [s for s in mock_database.views_snapshots.values() if s.purchase_id == purchase.id] - assert len(snapshots) == 1 - assert snapshots[0].views_count == 15000 + # Проверяем, что создана история просмотров + histories = [h for h in mock_database.placement_views_histories.values() if h.placement_id == placement.id] + assert len(histories) == 1 + assert histories[0].views_count == 15000 # Проверяем, что закуп обновлён - updated_purchase = await mock_database.get_purchase(user.id, purchase.id) - assert updated_purchase is not None - assert updated_purchase.views_count == 15000 - assert updated_purchase.views_availability == domain.PostViewsAvailability.MANUAL + updated_placement = await mock_database.get_placement(user.id, placement.id) + assert updated_placement is not None + assert updated_placement.views_count == 15000 + assert updated_placement.views_availability == domain.PostViewsAvailability.MANUAL @pytest.mark.asyncio @@ -239,7 +239,7 @@ async def test_get_views_history_empty(usecases: usecase.Usecase, mock_database: ) await mock_database.create_creative(creative) - purchase = domain.Purchase( + placement = domain.Placement( target_channel_id=target_channel.id, external_channel_id=external_channel.id, creative_id=creative.id, @@ -247,23 +247,23 @@ async def test_get_views_history_empty(usecases: usecase.Usecase, mock_database: placement_date=datetime.datetime.now(datetime.UTC), invite_link='https://t.me/+link4', invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PurchaseStatus.ACTIVE, + status=domain.PlacementStatus.ACTIVE, ) - await mock_database.create_purchase(purchase) + await mock_database.create_placement(placement) input_data = dto.GetViewsHistoryInput( - purchase_id=purchase.id, + placement_id=placement.id, user_id=user.id, ) result = await usecases.get_views_history(input=input_data) - assert len(result.snapshots) == 0 + assert len(result.histories) == 0 @pytest.mark.asyncio async def test_get_views_history_with_snapshots(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: - """Получение истории просмотров возвращает снимки в хронологическом порядке.""" + """Получение истории просмотров возвращает записи в хронологическом порядке.""" user = domain.User(telegram_id=123456, username='testuser') await mock_database.create_user(user) @@ -295,7 +295,7 @@ async def test_get_views_history_with_snapshots(usecases: usecase.Usecase, mock_ ) await mock_database.create_creative(creative) - purchase = domain.Purchase( + placement = domain.Placement( target_channel_id=target_channel.id, external_channel_id=external_channel.id, creative_id=creative.id, @@ -303,43 +303,43 @@ async def test_get_views_history_with_snapshots(usecases: usecase.Usecase, mock_ placement_date=datetime.datetime.now(datetime.UTC), invite_link='https://t.me/+link5', invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PurchaseStatus.ACTIVE, + status=domain.PlacementStatus.ACTIVE, ) - await mock_database.create_purchase(purchase) + await mock_database.create_placement(placement) - # Создаём несколько снимков + # Создаём несколько записей истории now = datetime.datetime.now(datetime.UTC) - snapshot1 = domain.ViewsSnapshot( - purchase_id=purchase.id, + history1 = domain.PlacementViewsHistory( + placement_id=placement.id, views_count=1000, fetched_at=now - datetime.timedelta(hours=2), ) - snapshot2 = domain.ViewsSnapshot( - purchase_id=purchase.id, + history2 = domain.PlacementViewsHistory( + placement_id=placement.id, views_count=1500, fetched_at=now - datetime.timedelta(hours=1), ) - snapshot3 = domain.ViewsSnapshot( - purchase_id=purchase.id, + history3 = domain.PlacementViewsHistory( + placement_id=placement.id, views_count=2000, fetched_at=now, ) - await mock_database.create_views_snapshot(snapshot1) - await mock_database.create_views_snapshot(snapshot2) - await mock_database.create_views_snapshot(snapshot3) + await mock_database.create_placement_views_history(history1) + await mock_database.create_placement_views_history(history2) + await mock_database.create_placement_views_history(history3) input_data = dto.GetViewsHistoryInput( - purchase_id=purchase.id, + placement_id=placement.id, user_id=user.id, ) result = await usecases.get_views_history(input=input_data) - assert len(result.snapshots) == 3 + assert len(result.histories) == 3 # Проверяем сортировку по времени (старые первые) - assert result.snapshots[0].views_count == 1000 - assert result.snapshots[1].views_count == 1500 - assert result.snapshots[2].views_count == 2000 + assert result.histories[0].views_count == 1000 + assert result.histories[1].views_count == 1500 + assert result.histories[2].views_count == 2000 @pytest.mark.asyncio @@ -376,7 +376,7 @@ async def test_get_views_history_with_date_filter(usecases: usecase.Usecase, moc ) await mock_database.create_creative(creative) - purchase = domain.Purchase( + placement = domain.Placement( target_channel_id=target_channel.id, external_channel_id=external_channel.id, creative_id=creative.id, @@ -384,41 +384,41 @@ async def test_get_views_history_with_date_filter(usecases: usecase.Usecase, moc placement_date=datetime.datetime.now(datetime.UTC), invite_link='https://t.me/+link6', invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PurchaseStatus.ACTIVE, + status=domain.PlacementStatus.ACTIVE, ) - await mock_database.create_purchase(purchase) + await mock_database.create_placement(placement) - # Создаём снимки с разными датами + # Создаём записи с разными датами now = datetime.datetime.now(datetime.UTC) - snapshot1 = domain.ViewsSnapshot( - purchase_id=purchase.id, + history1 = domain.PlacementViewsHistory( + placement_id=placement.id, views_count=1000, fetched_at=now - datetime.timedelta(days=3), ) - snapshot2 = domain.ViewsSnapshot( - purchase_id=purchase.id, + history2 = domain.PlacementViewsHistory( + placement_id=placement.id, views_count=1500, fetched_at=now - datetime.timedelta(days=1), ) - snapshot3 = domain.ViewsSnapshot( - purchase_id=purchase.id, + history3 = domain.PlacementViewsHistory( + placement_id=placement.id, views_count=2000, fetched_at=now, ) - await mock_database.create_views_snapshot(snapshot1) - await mock_database.create_views_snapshot(snapshot2) - await mock_database.create_views_snapshot(snapshot3) + await mock_database.create_placement_views_history(history1) + await mock_database.create_placement_views_history(history2) + await mock_database.create_placement_views_history(history3) # Запрашиваем историю за последние 2 дня input_data = dto.GetViewsHistoryInput( - purchase_id=purchase.id, + placement_id=placement.id, user_id=user.id, from_date=now - datetime.timedelta(days=2), ) result = await usecases.get_views_history(input=input_data) - # Должны вернуться только snapshot2 и snapshot3 - assert len(result.snapshots) == 2 - assert result.snapshots[0].views_count == 1500 - assert result.snapshots[1].views_count == 2000 + # Должны вернуться только history2 и history3 + assert len(result.histories) == 2 + assert result.histories[0].views_count == 1500 + assert result.histories[1].views_count == 2000