From 1f80d9bdfb9c71a5bcfdea206201e52ce13227f6 Mon Sep 17 00:00:00 2001 From: Artem Tsyrulnikov Date: Thu, 11 Dec 2025 18:02:46 +0300 Subject: [PATCH] =?UTF-8?q?ref:=20=D1=80=D0=B5=D1=84=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=B8=D0=BD=D0=B3=20usecases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/adapter/postgres.py | 156 ++--- src/adapter/telegram_bot.py | 5 - src/usecase/__init__.py | 49 +- .../analytics/get_creatives_analytics.py | 106 ++- .../get_external_channels_analytics.py | 109 ++- .../analytics/get_placements_analytics.py | 51 +- .../analytics/get_spending_analytics.py | 123 ++-- src/usecase/auth/get_me.py | 17 +- src/usecase/auth/telegram_login.py | 36 +- src/usecase/auth/telegram_start.py | 15 +- src/usecase/auth/validate_login_token.py | 27 +- src/usecase/creative/create_creative.py | 47 +- src/usecase/creative/delete_creative.py | 17 +- src/usecase/creative/get_creative.py | 29 +- src/usecase/creative/get_creatives.py | 5 +- .../creative/handle_creative_callback.py | 63 +- .../creative/handle_creative_text_input.py | 135 ++-- .../creative/start_creative_creation.py | 42 +- src/usecase/creative/update_creative.py | 43 +- .../create_external_channel.py | 29 +- .../delete_external_channel.py | 13 +- .../external_channel/get_external_channels.py | 13 +- .../update_external_channel.py | 45 +- .../update_external_channel_links.py | 36 +- src/usecase/placement/create_placement.py | 121 ++-- src/usecase/placement/delete_placement.py | 24 +- src/usecase/placement/get_placement.py | 51 +- src/usecase/placement/get_placements.py | 15 +- src/usecase/placement/update_placement.py | 70 +- .../subscription/handle_subscription.py | 108 +-- .../subscription/handle_unsubscription.py | 52 +- .../target_channel/connect_target_chan.py | 58 +- .../target_channel/disconnect_target_chan.py | 11 +- .../disconnect_target_chan_by_tg_id.py | 21 +- .../target_channel/get_user_target_chans.py | 3 +- .../update_target_chan_permissions.py | 49 +- src/usecase/views/get_views_history.py | 20 +- src/usecase/views/update_views_manually.py | 61 +- tests/conftest.py | 430 ------------ tests/test_auth.py | 107 --- tests/test_creative_text_formatting.py | 24 - tests/test_creatives.py | 244 ------- tests/test_external_channels.py | 177 ----- tests/test_placements.py | 632 ------------------ tests/test_subscriptions.py | 350 ---------- tests/test_target_channels.py | 169 ----- tests/test_views.py | 299 --------- 47 files changed, 853 insertions(+), 3454 deletions(-) delete mode 100644 tests/conftest.py delete mode 100644 tests/test_auth.py delete mode 100644 tests/test_creative_text_formatting.py delete mode 100644 tests/test_creatives.py delete mode 100644 tests/test_external_channels.py delete mode 100644 tests/test_placements.py delete mode 100644 tests/test_subscriptions.py delete mode 100644 tests/test_target_channels.py delete mode 100644 tests/test_views.py diff --git a/src/adapter/postgres.py b/src/adapter/postgres.py index b25ecc0..6dcbc6e 100644 --- a/src/adapter/postgres.py +++ b/src/adapter/postgres.py @@ -2,6 +2,7 @@ import datetime import typing import uuid +from tortoise import timezone from tortoise.transactions import in_transaction from shared.datebase_base import DatabaseBase @@ -12,31 +13,34 @@ class Postgres(DatabaseBase): def transaction(self) -> typing.AsyncContextManager[None]: return in_transaction() + async def create_user(self, user: domain.User) -> None: + await user.save() + + async def create_login_token(self, login_token: domain.LoginToken) -> None: + await login_token.save() + + async def create_external_channel(self, channel: domain.ExternalChannel) -> None: + await channel.save() + + async def create_creative(self, creative: domain.Creative) -> None: + await creative.save() + async def get_user(self, user_id: uuid.UUID | None = None, telegram_id: int | None = None) -> domain.User | None: if user_id: return await domain.User.get_or_none(id=user_id) elif telegram_id: return await domain.User.get_or_none(telegram_id=telegram_id) + raise ValueError('Either user_id or telegram_id must be provided') - async def create_user(self, user: domain.User) -> domain.User: - await user.save() - return user - - async def create_login_token( - self, user_id: uuid.UUID, token: str, expires_at: datetime.datetime - ) -> domain.LoginToken: - login_token = await domain.LoginToken.create(user_id=user_id, token=token, expires_at=expires_at) - return login_token - async def get_login_token(self, token: str) -> domain.LoginToken | None: return await domain.LoginToken.get_or_none(token=token) async def mark_token_as_used(self, token: str) -> None: - login_token = await domain.LoginToken.get_or_none(token=token) - if login_token: - login_token.used_at = datetime.datetime.now(datetime.UTC) - await login_token.save() + updated = await domain.LoginToken.filter(token=token, used_at__isnull=True).update(used_at=timezone.now()) + + if updated == 0: + raise domain.LoginTokenAlreadyUsed() # либо нет токена, либо он уже использован async def get_target_channel( self, user_id: uuid.UUID, channel_id: uuid.UUID | None = None, telegram_id: int | None = None @@ -44,36 +48,34 @@ class Postgres(DatabaseBase): if not channel_id and not telegram_id: raise ValueError('Either channel_id or telegram_id must be provided') - query = domain.TargetChannel.filter(user_id=user_id) + q = domain.TargetChannel.filter(user_id=user_id) if channel_id: - query = query.filter(id=channel_id) + q = q.filter(id=channel_id) if telegram_id: - query = query.filter(telegram_id=telegram_id) + q = q.filter(telegram_id=telegram_id) - return await query.first() + return await q.first() - async def upsert_target_channel(self, channel: domain.TargetChannel) -> domain.TargetChannel: + async def upsert_target_channel(self, channel: domain.TargetChannel) -> None: existing = await self.get_target_channel(user_id=channel.user_id, telegram_id=channel.telegram_id) if not existing: await channel.save() - return channel + return existing.title = channel.title existing.username = channel.username existing.user_id = channel.user_id existing.status = channel.status existing.deleted_at = None - existing.updated_at = datetime.datetime.now(datetime.UTC) + existing.updated_at = timezone.now() await existing.save() - return existing async def get_user_target_channels(self, user_id: uuid.UUID) -> list[domain.TargetChannel]: return await domain.TargetChannel.filter(user_id=user_id, status=domain.TargetChannelStatus.ACTIVE).all() - async def update_target_channel(self, channel: domain.TargetChannel) -> domain.TargetChannel: + async def update_target_channel(self, channel: domain.TargetChannel) -> None: await channel.save() - return channel async def get_external_channel( self, user_id: uuid.UUID, channel_id: uuid.UUID | None = None, telegram_id: int | None = None @@ -86,14 +88,8 @@ class Postgres(DatabaseBase): return await query.first() - async def create_external_channel(self, channel: domain.ExternalChannel) -> domain.ExternalChannel: - await channel.save() - return channel - async def get_external_channels_for_target(self, target_channel_id: uuid.UUID) -> list[domain.ExternalChannel]: - target = await ( - domain.TargetChannel.filter(id=target_channel_id).prefetch_related('external_channels').first() - ) + target = await domain.TargetChannel.filter(id=target_channel_id).prefetch_related('external_channels').first() if not target: return [] return await target.external_channels.all() @@ -129,22 +125,14 @@ class Postgres(DatabaseBase): async def delete_external_channel(self, channel_id: uuid.UUID) -> None: await domain.ExternalChannel.filter(id=channel_id).delete() - async def update_external_channel(self, channel: domain.ExternalChannel) -> domain.ExternalChannel: + async def update_external_channel(self, channel: domain.ExternalChannel) -> None: await channel.save() - return channel async def get_creative(self, user_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Creative | None: return await domain.Creative.get_or_none(id=creative_id, user_id=user_id).prefetch_related('target_channel') - async def create_creative(self, creative: domain.Creative) -> domain.Creative: + async def update_creative(self, creative: domain.Creative) -> None: await creative.save() - await creative.fetch_related('target_channel') - return creative - - async def update_creative(self, creative: domain.Creative) -> domain.Creative: - await creative.save() - await creative.fetch_related('target_channel') - return creative async def get_user_creatives( self, user_id: uuid.UUID, target_channel_id: uuid.UUID | None = None, include_archived: bool = False @@ -167,15 +155,11 @@ class Postgres(DatabaseBase): 'target_channel', 'external_channel', 'creative' ) - async def create_placement(self, placement: domain.Placement) -> domain.Placement: + async def create_placement(self, placement: domain.Placement) -> None: await placement.save() - await placement.fetch_related('target_channel', 'external_channel', 'creative') - return placement - async def update_placement(self, placement: domain.Placement) -> domain.Placement: + async def update_placement(self, placement: domain.Placement) -> None: await placement.save() - await placement.fetch_related('target_channel', 'external_channel', 'creative') - return placement async def get_user_placements( self, @@ -211,78 +195,30 @@ class Postgres(DatabaseBase): 'target_channel', 'external_channel', 'creative' ) - async def get_placement_by_id(self, placement_id: uuid.UUID) -> domain.Placement | None: - return await domain.Placement.get_or_none(id=placement_id) - - async def update_placement_from_post_found( - self, placement_id: uuid.UUID, ad_post_url: str, message_id: int, views_count: int - ) -> domain.Placement: - placement = await domain.Placement.get_or_none(id=placement_id) - if not placement: - raise ValueError(f'Placement {placement_id} not found') - - placement.status = domain.PlacementStatus.ACTIVE - placement.ad_post_url = ad_post_url - placement.external_channel_message_id = message_id - placement.views_count = views_count - placement.views_availability = domain.PostViewsAvailability.AVAILABLE - placement.last_views_fetch_at = datetime.datetime.now(datetime.UTC) - - await placement.save() - return placement - - async def update_placement_from_post_deleted(self, placement_id: uuid.UUID) -> domain.Placement: - placement = await domain.Placement.get_or_none(id=placement_id) - if not placement: - raise ValueError(f'Placement {placement_id} not found') - - placement.status = domain.PlacementStatus.DELETED - await placement.save() - return placement - - async def update_placement_views(self, placement_id: uuid.UUID, views_count: int) -> domain.Placement: - placement = await domain.Placement.get_or_none(id=placement_id) - if not placement: - raise ValueError(f'Placement {placement_id} not found') - - placement.views_count = views_count - placement.last_views_fetch_at = datetime.datetime.now(datetime.UTC) - - await placement.save() - return placement - async def get_subscriber(self, telegram_id: int) -> domain.Subscriber | None: return await domain.Subscriber.get_or_none(telegram_id=telegram_id) - async def create_subscriber(self, subscriber: domain.Subscriber) -> domain.Subscriber: - await subscriber.save() - return subscriber - - async def upsert_subscriber(self, subscriber: domain.Subscriber) -> domain.Subscriber: + async def upsert_subscriber(self, subscriber: domain.Subscriber) -> None: 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 existing.save() - return existing - return await self.create_subscriber(subscriber) + if not existing: + await subscriber.save() + return - async def create_subscription(self, subscription: domain.Subscription) -> domain.Subscription: + existing.username = subscriber.username + existing.first_name = subscriber.first_name + existing.last_name = subscriber.last_name + await existing.save() + + async def create_subscription(self, subscription: domain.Subscription) -> None: await subscription.save() - await subscription.fetch_related('placement', 'subscriber') - return subscription async def get_subscription_by_subscriber_and_placement( self, subscriber_id: uuid.UUID, placement_id: uuid.UUID ) -> domain.Subscription | None: return await domain.Subscription.get_or_none(subscriber_id=subscriber_id, placement_id=placement_id) - async def update_subscription(self, subscription: domain.Subscription) -> domain.Subscription: + async def update_subscription(self, subscription: domain.Subscription) -> None: await subscription.save() - await subscription.fetch_related('placement', 'subscriber') - return subscription async def get_active_subscriptions_by_subscriber_and_channel( self, subscriber_id: uuid.UUID, channel_telegram_id: int @@ -310,12 +246,8 @@ class Postgres(DatabaseBase): .first() ) - async def create_placement_views_history( - self, history: domain.PlacementViewsHistory - ) -> domain.PlacementViewsHistory: + async def create_placement_views_history(self, history: domain.PlacementViewsHistory) -> None: await history.save() - await history.fetch_related('placement') - return history async def get_views_history( self, @@ -344,7 +276,7 @@ class Postgres(DatabaseBase): if existing: existing.state = state existing.context = context - existing.updated_at = datetime.datetime.now(datetime.UTC) + existing.updated_at = timezone.now() await existing.save() return existing diff --git a/src/adapter/telegram_bot.py b/src/adapter/telegram_bot.py index b8f2d1c..bed54e9 100644 --- a/src/adapter/telegram_bot.py +++ b/src/adapter/telegram_bot.py @@ -1,5 +1,4 @@ import logging -from typing import Any from aiogram.types import BotCommand, InlineKeyboardButton, InlineKeyboardMarkup @@ -16,10 +15,6 @@ class TelegramBot(TelegramBase): keyboard = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text=button_text, url=button_url)]]) await self.bot.send_message(chat_id=chat_id, text=text, reply_markup=keyboard) - async def get_chat_member(self, chat_id: int, user_id: int) -> dict[str, Any]: - member = await self.bot.get_chat_member(chat_id=chat_id, user_id=user_id) - return member.model_dump() - async def create_chat_invite_link(self, chat_id: int, requires_approval: bool = False) -> str: invite_link = await self.bot.create_chat_invite_link(chat_id=chat_id, creates_join_request=requires_approval) return invite_link.invite_link diff --git a/src/usecase/__init__.py b/src/usecase/__init__.py index 0f44ae8..456ba11 100644 --- a/src/usecase/__init__.py +++ b/src/usecase/__init__.py @@ -50,11 +50,11 @@ class Database(typing.Protocol): async def get_user(self, user_id: UUID | None = None, telegram_id: int | None = None) -> domain.User | None: ... - async def create_user(self, user: domain.User) -> domain.User: ... + async def create_user(self, user: domain.User) -> None: ... - async def create_login_token( - self, user_id: UUID, token: str, expires_at: datetime.datetime - ) -> domain.LoginToken: ... + async def create_login_token(self, login_token: domain.LoginToken) -> None: ... + + async def create_creative(self, creative: domain.Creative) -> None: ... async def get_login_token(self, token: str) -> domain.LoginToken | None: ... @@ -64,17 +64,17 @@ class Database(typing.Protocol): self, user_id: UUID, channel_id: UUID | None = None, telegram_id: int | None = None ) -> domain.TargetChannel | None: ... - async def upsert_target_channel(self, channel: domain.TargetChannel) -> domain.TargetChannel: ... + async def upsert_target_channel(self, channel: domain.TargetChannel) -> None: ... async def get_user_target_channels(self, user_id: UUID) -> list[domain.TargetChannel]: ... - async def update_target_channel(self, channel: domain.TargetChannel) -> domain.TargetChannel: ... + async def update_target_channel(self, channel: domain.TargetChannel) -> None: ... async def get_external_channel( self, user_id: UUID, channel_id: UUID | None = None, telegram_id: int | None = None ) -> domain.ExternalChannel | None: ... - async def create_external_channel(self, channel: domain.ExternalChannel) -> domain.ExternalChannel: ... + async def create_external_channel(self, channel: domain.ExternalChannel) -> None: ... async def get_external_channels_for_target(self, target_channel_id: UUID) -> list[domain.ExternalChannel]: ... @@ -88,13 +88,11 @@ class Database(typing.Protocol): async def delete_external_channel(self, channel_id: UUID) -> None: ... - async def update_external_channel(self, channel: domain.ExternalChannel) -> domain.ExternalChannel: ... + async def update_external_channel(self, channel: domain.ExternalChannel) -> None: ... async def get_creative(self, user_id: UUID, creative_id: UUID) -> domain.Creative | None: ... - async def create_creative(self, creative: domain.Creative) -> domain.Creative: ... - - async def update_creative(self, creative: domain.Creative) -> domain.Creative: ... + async def update_creative(self, creative: domain.Creative) -> None: ... async def get_user_creatives( self, user_id: UUID, target_channel_id: UUID | None = None, include_archived: bool = False @@ -104,9 +102,9 @@ class Database(typing.Protocol): async def get_placement(self, user_id: UUID, placement_id: UUID) -> domain.Placement | None: ... - async def create_placement(self, placement: domain.Placement) -> domain.Placement: ... + async def create_placement(self, placement: domain.Placement) -> None: ... - async def update_placement(self, placement: domain.Placement) -> domain.Placement: ... + async def update_placement(self, placement: domain.Placement) -> None: ... async def get_user_placements( self, @@ -123,17 +121,15 @@ class Database(typing.Protocol): 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) -> None: ... - async def upsert_subscriber(self, subscriber: domain.Subscriber) -> domain.Subscriber: ... - - async def create_subscription(self, subscription: domain.Subscription) -> domain.Subscription: ... + async def create_subscription(self, subscription: domain.Subscription) -> None: ... async def get_subscription_by_subscriber_and_placement( self, subscriber_id: UUID, placement_id: UUID ) -> domain.Subscription | None: ... - async def update_subscription(self, subscription: domain.Subscription) -> domain.Subscription: ... + async def update_subscription(self, subscription: domain.Subscription) -> None: ... async def get_active_subscriptions_by_subscriber_and_channel( self, subscriber_id: UUID, channel_telegram_id: int @@ -143,9 +139,7 @@ class Database(typing.Protocol): self, subscriber_id: UUID, channel_telegram_id: int ) -> domain.Subscription | None: ... - async def create_placement_views_history( - self, history: domain.PlacementViewsHistory - ) -> domain.PlacementViewsHistory: ... + async def create_placement_views_history(self, history: domain.PlacementViewsHistory) -> None: ... async def get_views_history( self, @@ -163,17 +157,6 @@ class Database(typing.Protocol): async def clear_telegram_state(self, telegram_id: int) -> None: ... - # NATS-related methods for placement tracking - async def get_placement_by_id(self, placement_id: UUID) -> domain.Placement | None: ... - - async def update_placement_from_post_found( - self, placement_id: UUID, ad_post_url: str, message_id: int, views_count: int - ) -> domain.Placement: ... - - async def update_placement_from_post_deleted(self, placement_id: UUID) -> domain.Placement: ... - - async def update_placement_views(self, placement_id: UUID, views_count: int) -> domain.Placement: ... - class TelegramBotWriter(typing.Protocol): async def send_message(self, text: str, chat_id: int) -> None: ... @@ -186,8 +169,6 @@ class TelegramBotWriter(typing.Protocol): async def edit_message_reply_markup(self, chat_id: int, message_id: int) -> None: ... - async def get_chat_member(self, chat_id: int, user_id: int) -> dict[str, typing.Any]: ... - async def create_chat_invite_link(self, chat_id: int, requires_approval: bool = False) -> str: ... diff --git a/src/usecase/analytics/get_creatives_analytics.py b/src/usecase/analytics/get_creatives_analytics.py index 10d1964..5f1d294 100644 --- a/src/usecase/analytics/get_creatives_analytics.py +++ b/src/usecase/analytics/get_creatives_analytics.py @@ -14,65 +14,57 @@ log = logging.getLogger(__name__) async def get_creatives_analytics( self: 'Usecase', input: dto.GetCreativesAnalyticsInput ) -> dto.GetCreativesAnalyticsOutput: - async with self.database.transaction(): - if input.target_channel_id: - target_channel = await self.database.get_target_channel(input.user_id, input.target_channel_id) - if not target_channel: - raise domain.TargetChannelNotFound(input.target_channel_id) + if input.target_channel_id: + target_channel = await self.database.get_target_channel(input.user_id, input.target_channel_id) + if not target_channel: + raise domain.TargetChannelNotFound(input.target_channel_id) - creatives = await self.database.get_user_creatives( - input.user_id, input.target_channel_id, include_archived=False + creatives = await self.database.get_user_creatives(input.user_id, input.target_channel_id, include_archived=False) + placements = await self.database.get_user_placements(input.user_id, input.target_channel_id, include_archived=False) + + @dataclass + class CreativeStats: + total_cost: float = 0.0 + total_subscriptions: int = 0 + total_views: int = 0 + placements_count: int = 0 + + creative_stats: dict[UUID, CreativeStats] = {cr.id: CreativeStats() for cr in creatives} + + for p in placements: + stats = creative_stats.get(p.creative_id) + if stats is None: + continue + + stats.total_cost += p.cost if p.cost is not None else 0 + stats.total_subscriptions += p.subscriptions_count + stats.total_views += p.views_count if p.views_count is not None else 0 + stats.placements_count += 1 + + result = [] + for cr in creatives: + stats = creative_stats[cr.id] + + avg_cpf = ( + (stats.total_cost / stats.total_subscriptions) + if stats.total_subscriptions > 0 and stats.total_cost > 0 + else None + ) + avg_cpm = ( + (stats.total_cost / stats.total_views * 1000) if stats.total_views > 0 and stats.total_cost > 0 else None ) - placements = await self.database.get_user_placements( - input.user_id, input.target_channel_id, include_archived=False + result.append( + dto.CreativeAnalyticsOutput( + id=cr.id, + name=cr.name, + placements_count=stats.placements_count, + total_cost=stats.total_cost, + total_subscriptions=stats.total_subscriptions, + total_views=stats.total_views, + avg_cpf=avg_cpf, + avg_cpm=avg_cpm, + ) ) - @dataclass - class CreativeStats: - total_cost: float = 0.0 - total_subscriptions: int = 0 - total_views: int = 0 - placements_count: int = 0 - - creative_stats: dict[UUID, CreativeStats] = {cr.id: CreativeStats() for cr in creatives} - - for p in placements: - stats = creative_stats.get(p.creative_id) - if stats is None: - continue - - stats.total_cost += p.cost if p.cost is not None else 0 - stats.total_subscriptions += p.subscriptions_count - stats.total_views += p.views_count if p.views_count is not None else 0 - stats.placements_count += 1 - - result = [] - for cr in creatives: - stats = creative_stats[cr.id] - - avg_cpf = ( - (stats.total_cost / stats.total_subscriptions) - if stats.total_subscriptions > 0 and stats.total_cost > 0 - else None - ) - avg_cpm = ( - (stats.total_cost / stats.total_views * 1000) - if stats.total_views > 0 and stats.total_cost > 0 - else None - ) - - result.append( - dto.CreativeAnalyticsOutput( - id=cr.id, - name=cr.name, - placements_count=stats.placements_count, - total_cost=stats.total_cost, - total_subscriptions=stats.total_subscriptions, - total_views=stats.total_views, - avg_cpf=avg_cpf, - avg_cpm=avg_cpm, - ) - ) - - return dto.GetCreativesAnalyticsOutput(creatives=result) + return dto.GetCreativesAnalyticsOutput(creatives=result) diff --git a/src/usecase/analytics/get_external_channels_analytics.py b/src/usecase/analytics/get_external_channels_analytics.py index 362978a..3843c68 100644 --- a/src/usecase/analytics/get_external_channels_analytics.py +++ b/src/usecase/analytics/get_external_channels_analytics.py @@ -14,65 +14,60 @@ log = logging.getLogger(__name__) async def get_external_channels_analytics( self: 'Usecase', input: dto.GetExternalChannelsAnalyticsInput ) -> dto.GetExternalChannelsAnalyticsOutput: - async with self.database.transaction(): - if input.target_channel_id: - target_channel = await self.database.get_target_channel(input.user_id, input.target_channel_id) - if not target_channel: - raise domain.TargetChannelNotFound(input.target_channel_id) - external_channels = await self.database.get_external_channels_for_target(input.target_channel_id) - else: - external_channels = await self.database.get_user_external_channels(input.user_id) + if input.target_channel_id: + target_channel = await self.database.get_target_channel(input.user_id, input.target_channel_id) + if not target_channel: + raise domain.TargetChannelNotFound(input.target_channel_id) + external_channels = await self.database.get_external_channels_for_target(input.target_channel_id) + else: + external_channels = await self.database.get_user_external_channels(input.user_id) - placements = await self.database.get_user_placements( - input.user_id, input.target_channel_id, include_archived=False + placements = await self.database.get_user_placements(input.user_id, input.target_channel_id, include_archived=False) + + @dataclass + class ChannelStats: + total_cost: float = 0.0 + total_subscriptions: int = 0 + total_views: int = 0 + placements_count: int = 0 + + channel_stats: dict[UUID, ChannelStats] = {ch.id: ChannelStats() for ch in external_channels} + + for p in placements: + stats = channel_stats.get(p.external_channel_id) + if stats is None: + continue + + stats.total_cost += p.cost if p.cost is not None else 0 + stats.total_subscriptions += p.subscriptions_count + stats.total_views += p.views_count if p.views_count is not None else 0 + stats.placements_count += 1 + + result = [] + for ch in external_channels: + stats = channel_stats[ch.id] + + avg_cpf = ( + (stats.total_cost / stats.total_subscriptions) + if stats.total_subscriptions > 0 and stats.total_cost > 0 + else None + ) + avg_cpm = ( + (stats.total_cost / stats.total_views * 1000) if stats.total_views > 0 and stats.total_cost > 0 else None ) - @dataclass - class ChannelStats: - total_cost: float = 0.0 - total_subscriptions: int = 0 - total_views: int = 0 - placements_count: int = 0 - - channel_stats: dict[UUID, ChannelStats] = {ch.id: ChannelStats() for ch in external_channels} - - for p in placements: - stats = channel_stats.get(p.external_channel_id) - if stats is None: - continue - - stats.total_cost += p.cost if p.cost is not None else 0 - stats.total_subscriptions += p.subscriptions_count - stats.total_views += p.views_count if p.views_count is not None else 0 - stats.placements_count += 1 - - result = [] - for ch in external_channels: - stats = channel_stats[ch.id] - - avg_cpf = ( - (stats.total_cost / stats.total_subscriptions) - if stats.total_subscriptions > 0 and stats.total_cost > 0 - else None - ) - avg_cpm = ( - (stats.total_cost / stats.total_views * 1000) - if stats.total_views > 0 and stats.total_cost > 0 - else None + result.append( + dto.ExternalChannelAnalyticsOutput( + id=ch.id, + title=ch.title, + username=ch.username, + placements_count=stats.placements_count, + total_cost=stats.total_cost, + total_subscriptions=stats.total_subscriptions, + total_views=stats.total_views, + avg_cpf=avg_cpf, + avg_cpm=avg_cpm, ) + ) - result.append( - dto.ExternalChannelAnalyticsOutput( - id=ch.id, - title=ch.title, - username=ch.username, - placements_count=stats.placements_count, - total_cost=stats.total_cost, - total_subscriptions=stats.total_subscriptions, - total_views=stats.total_views, - avg_cpf=avg_cpf, - avg_cpm=avg_cpm, - ) - ) - - return dto.GetExternalChannelsAnalyticsOutput(external_channels=result) + return dto.GetExternalChannelsAnalyticsOutput(external_channels=result) diff --git a/src/usecase/analytics/get_placements_analytics.py b/src/usecase/analytics/get_placements_analytics.py index 315070e..847569f 100644 --- a/src/usecase/analytics/get_placements_analytics.py +++ b/src/usecase/analytics/get_placements_analytics.py @@ -24,33 +24,30 @@ def _calculate_cpm(cost: float | None, views: int | None) -> float | None: async def get_placements_analytics( self: 'Usecase', input: dto.GetPlacementsAnalyticsInput ) -> dto.GetPlacementsAnalyticsOutput: - async with self.database.transaction(): - if input.target_channel_id: - target_channel = await self.database.get_target_channel(input.user_id, input.target_channel_id) - if not target_channel: - raise domain.TargetChannelNotFound(input.target_channel_id) + if input.target_channel_id: + target_channel = await self.database.get_target_channel(input.user_id, input.target_channel_id) + if not target_channel: + raise domain.TargetChannelNotFound(input.target_channel_id) - placements = await self.database.get_user_placements( - input.user_id, input.target_channel_id, include_archived=False + placements = await self.database.get_user_placements(input.user_id, input.target_channel_id, include_archived=False) + + result = [ + dto.PlacementAnalyticsOutput( + id=p.id, + target_channel_id=p.target_channel_id, + target_channel_title=p.target_channel.title, + external_channel_id=p.external_channel_id, + external_channel_title=p.external_channel.title, + creative_id=p.creative_id, + creative_name=p.creative.name, + placement_date=p.placement_date, + cost=p.cost, + subscriptions_count=p.subscriptions_count, + views_count=p.views_count, + cpf=_calculate_cpf(p.cost, p.subscriptions_count), + cpm=_calculate_cpm(p.cost, p.views_count), ) + for p in placements + ] - result = [ - dto.PlacementAnalyticsOutput( - id=p.id, - target_channel_id=p.target_channel_id, - target_channel_title=p.target_channel.title, - external_channel_id=p.external_channel_id, - external_channel_title=p.external_channel.title, - creative_id=p.creative_id, - creative_name=p.creative.name, - placement_date=p.placement_date, - cost=p.cost, - subscriptions_count=p.subscriptions_count, - views_count=p.views_count, - cpf=_calculate_cpf(p.cost, p.subscriptions_count), - cpm=_calculate_cpm(p.cost, p.views_count), - ) - for p in placements - ] - - return dto.GetPlacementsAnalyticsOutput(placements=result) + return dto.GetPlacementsAnalyticsOutput(placements=result) diff --git a/src/usecase/analytics/get_spending_analytics.py b/src/usecase/analytics/get_spending_analytics.py index 68c3a00..3bab756 100644 --- a/src/usecase/analytics/get_spending_analytics.py +++ b/src/usecase/analytics/get_spending_analytics.py @@ -33,82 +33,79 @@ def _format_period(dt: 'datetime.datetime', grouping: dto.DateGrouping) -> str: async def get_spending_analytics( self: 'Usecase', input: dto.GetSpendingAnalyticsInput ) -> dto.GetSpendingAnalyticsOutput: - async with self.database.transaction(): - if input.target_channel_id: - target_channel = await self.database.get_target_channel(input.user_id, input.target_channel_id) - if not target_channel: - raise domain.TargetChannelNotFound(input.target_channel_id) + if input.target_channel_id: + target_channel = await self.database.get_target_channel(input.user_id, input.target_channel_id) + if not target_channel: + raise domain.TargetChannelNotFound(input.target_channel_id) - placements = await self.database.get_user_placements( - input.user_id, input.target_channel_id, include_archived=False - ) + placements = await self.database.get_user_placements(input.user_id, input.target_channel_id, include_archived=False) - filtered = [ - p - for p in placements - if (not input.date_from or p.placement_date >= input.date_from) - and (not input.date_to or p.placement_date <= input.date_to) - ] + filtered = [ + p + for p in placements + if (not input.date_from or p.placement_date >= input.date_from) + and (not input.date_to or p.placement_date <= input.date_to) + ] - total_cost = 0.0 - total_subs = 0 - total_views = 0 + total_cost = 0.0 + total_subs = 0 + total_views = 0 - @dataclass - class PeriodData: - cost: float = 0.0 - subscriptions: int = 0 - views: int = 0 + @dataclass + class PeriodData: + cost: float = 0.0 + subscriptions: int = 0 + views: int = 0 - # Группировка по периодам - period_data: dict[str, PeriodData] = defaultdict(PeriodData) + # Группировка по периодам + period_data: dict[str, PeriodData] = defaultdict(PeriodData) - for p in filtered: - period = _format_period(p.placement_date, input.grouping) - pd = period_data[period] + for p in filtered: + period = _format_period(p.placement_date, input.grouping) + pd = period_data[period] - if p.cost is not None: - total_cost += p.cost - pd.cost += p.cost + if p.cost is not None: + total_cost += p.cost + pd.cost += p.cost - total_subs += p.subscriptions_count - pd.subscriptions += p.subscriptions_count + total_subs += p.subscriptions_count + pd.subscriptions += p.subscriptions_count - if p.views_count is not None: - total_views += p.views_count - pd.views += p.views_count + if p.views_count is not None: + total_views += p.views_count + pd.views += p.views_count - avg_cpf = total_cost / total_subs if total_subs > 0 and total_cost > 0 else None - avg_cpm = (total_cost / total_views * 1000) if total_views > 0 and total_cost > 0 else None + avg_cpf = total_cost / total_subs if total_subs > 0 and total_cost > 0 else None + avg_cpm = (total_cost / total_views * 1000) if total_views > 0 and total_cost > 0 else None - # Данные для графика - chart_data = [] - for period in sorted(period_data.keys()): - d = period_data[period] + # Данные для графика + chart_data = [] + for period in sorted(period_data.keys()): + d = period_data[period] - cost = d.cost - subs = d.subscriptions - views = d.views + cost = d.cost + subs = d.subscriptions + views = d.views - cpf = cost / subs if subs > 0 and cost > 0 else None - cpm = (cost / views * 1000) if views > 0 and cost > 0 else None + cpf = cost / subs if subs > 0 and cost > 0 else None + cpm = (cost / views * 1000) if views > 0 and cost > 0 else None - chart_data.append( - dto.SpendingDataPoint( - period=period, - cost=cost, - subscriptions=subs, - views=views, - cpf=cpf, - cpm=cpm, - ) + chart_data.append( + dto.SpendingDataPoint( + period=period, + cost=cost, + subscriptions=subs, + views=views, + cpf=cpf, + cpm=cpm, ) - - return dto.GetSpendingAnalyticsOutput( - total_cost=total_cost, - total_subscriptions=total_subs, - total_views=total_views, - avg_cpf=avg_cpf, - avg_cpm=avg_cpm, - chart_data=chart_data, ) + + return dto.GetSpendingAnalyticsOutput( + total_cost=total_cost, + total_subscriptions=total_subs, + total_views=total_views, + avg_cpf=avg_cpf, + avg_cpm=avg_cpm, + chart_data=chart_data, + ) diff --git a/src/usecase/auth/get_me.py b/src/usecase/auth/get_me.py index 26e87a4..cb66bc8 100644 --- a/src/usecase/auth/get_me.py +++ b/src/usecase/auth/get_me.py @@ -8,14 +8,13 @@ if typing.TYPE_CHECKING: async def get_me(self: 'Usecase', user_id: uuid.UUID) -> dto.UserOutput: - async with self.database.transaction(): - user = await self.database.get_user(user_id=user_id) + user = await self.database.get_user(user_id=user_id) - if not user: - raise domain.UserNotFound(user_id) + if not user: + raise domain.UserNotFound(user_id) - return dto.UserOutput( - id=user.id, - telegram_id=user.telegram_id, - username=user.username, - ) + return dto.UserOutput( + id=user.id, + telegram_id=user.telegram_id, + username=user.username, + ) diff --git a/src/usecase/auth/telegram_login.py b/src/usecase/auth/telegram_login.py index cd8571b..f9e1206 100644 --- a/src/usecase/auth/telegram_login.py +++ b/src/usecase/auth/telegram_login.py @@ -2,6 +2,8 @@ import datetime import secrets import typing +from tortoise import timezone + from src import domain, dto from src.config import settings @@ -10,27 +12,29 @@ if typing.TYPE_CHECKING: async def telegram_login(self: 'Usecase', input: dto.TelegramLoginInput) -> None: - async with self.database.transaction(): - user: domain.User | None = await self.database.get_user(telegram_id=input.telegram_id) + user = await self.database.get_user(telegram_id=input.telegram_id) + if not user: + user = domain.User( + telegram_id=input.telegram_id, + username=input.username, + ) + await self.database.create_user(user) - if not user: - user = domain.User( - telegram_id=input.telegram_id, - username=input.username, - ) - user = await self.database.create_user(user) + token = secrets.token_urlsafe(32) + expires_at = timezone.now() + datetime.timedelta(minutes=10) - token = secrets.token_urlsafe(32) - expires_at = datetime.datetime.now(datetime.UTC) + datetime.timedelta(minutes=10) + login_token = domain.LoginToken( + token=token, + user=user, + expires_at=expires_at, + ) + await self.database.create_login_token(login_token) - await self.database.create_login_token(user.id, token, expires_at) + login_url = f'{settings.app.URL}{login_token.token}' - login_url = f'{settings.app.URL}{token}' - - username_display = f'@{user.username}' if user.username else 'пользователь' await self.telegram_bot.send_message_with_button( - f'Привет, {username_display}!\n\nНажми кнопку ниже для входа на сайт.', - chat_id=input.chat_id, + f'Привет, {user.username or "аноним"}!\n\nНажми кнопку ниже для входа на сайт.', + input.chat_id, button_text='Войти на сайт', button_url=login_url, ) diff --git a/src/usecase/auth/telegram_start.py b/src/usecase/auth/telegram_start.py index 285aae6..2f8ba92 100644 --- a/src/usecase/auth/telegram_start.py +++ b/src/usecase/auth/telegram_start.py @@ -8,13 +8,12 @@ log = logging.getLogger(__name__) async def telegram_start(self: 'Usecase', telegram_id: int, chat_id: int) -> None: - async with self.database.transaction(): - user = await self.database.get_user(telegram_id=telegram_id) + user = await self.database.get_user(telegram_id=telegram_id) - if user: - username_display = f'@{user.username}' if user.username else 'пользователь' - message = f'Привет, {username_display}! 👋\n\nДоступные команды:\n/new_creative - Создать новый креатив' - else: - message = 'Привет! 👋\n\nДля работы с ботом необходимо авторизоваться через веб-интерфейс.' + if not user: + message = 'Привет! 👋\n\nДля работы с ботом необходимо авторизоваться через веб-интерфейс.' + await self.telegram_bot.send_message(message, chat_id) + return - await self.telegram_bot.send_message(message, chat_id=chat_id) + message = f'Привет, {user.username or "аноним"}! 👋\n\nДоступные команды:\n/new_creative - Создать новый креатив' + await self.telegram_bot.send_message(message, chat_id) diff --git a/src/usecase/auth/validate_login_token.py b/src/usecase/auth/validate_login_token.py index a703184..26cb2c2 100644 --- a/src/usecase/auth/validate_login_token.py +++ b/src/usecase/auth/validate_login_token.py @@ -1,6 +1,7 @@ -import datetime import typing +from tortoise import timezone + from src import domain, dto if typing.TYPE_CHECKING: @@ -8,25 +9,23 @@ if typing.TYPE_CHECKING: async def validate_login_token(self: 'Usecase', input: dto.ValidateLoginTokenInput) -> dto.ValidateLoginTokenOutput: - async with self.database.transaction(): - login_token = await self.database.get_login_token(input.token) + login_token = await self.database.get_login_token(input.token) - if not login_token: - raise domain.LoginTokenNotFound() + if not login_token: + raise domain.LoginTokenNotFound() - if login_token.used_at: - raise domain.LoginTokenAlreadyUsed() + if login_token.used_at: + raise domain.LoginTokenAlreadyUsed() - if login_token.expires_at < datetime.datetime.now(datetime.UTC): - raise domain.LoginTokenExpired() + if login_token.expires_at < timezone.now(): + raise domain.LoginTokenExpired() - user = await self.database.get_user(user_id=login_token.user_id) - if not user: - raise domain.UserNotFound(login_token.user_id) + user = await self.database.get_user(user_id=login_token.user_id) + if not user: + raise domain.UserNotFound(login_token.user_id) - await self.database.mark_token_as_used(input.token) + await self.database.mark_token_as_used(input.token) - # Генерируем JWT токен с информацией о пользователе access_token = self.jwt_encoder.encode_access_token( user_id=user.id, telegram_id=user.telegram_id, diff --git a/src/usecase/creative/create_creative.py b/src/usecase/creative/create_creative.py index 16aef42..23c2438 100644 --- a/src/usecase/creative/create_creative.py +++ b/src/usecase/creative/create_creative.py @@ -11,31 +11,28 @@ log = logging.getLogger(__name__) async def create_creative(self: 'Usecase', input: dto.CreateCreativeInput, user_id: uuid.UUID) -> dto.CreativeOutput: - 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 creative for unavailable target %s', user_id, input.target_channel_id - ) - raise domain.TargetChannelNotFound(input.target_channel_id) + 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 creative for unavailable target %s', user_id, input.target_channel_id) + raise domain.TargetChannelNotFound(input.target_channel_id) - creative = domain.Creative( - name=input.name, - text=input.text, - status=domain.CreativeStatus.ACTIVE, - target_channel_id=target_channel.id, - user_id=user_id, - ) + creative = domain.Creative( + name=input.name, + text=input.text, + status=domain.CreativeStatus.ACTIVE, + target_channel_id=target_channel.id, + user_id=user_id, + ) - created = await self.database.create_creative(creative) + await self.database.create_creative(creative) - return dto.CreativeOutput( - id=created.id, - name=created.name, - text=created.text, - target_channel_id=created.target_channel_id, - target_channel_title=created.target_channel.title, - created_at=created.created_at, - status=created.status, - placements_count=created.placements_count, - ) + return dto.CreativeOutput( + id=creative.id, + name=creative.name, + text=creative.text, + target_channel_id=target_channel.id, + target_channel_title=target_channel.title, + created_at=creative.created_at, + status=creative.status, + placements_count=creative.placements_count, + ) diff --git a/src/usecase/creative/delete_creative.py b/src/usecase/creative/delete_creative.py index 4861e60..f60cd7b 100644 --- a/src/usecase/creative/delete_creative.py +++ b/src/usecase/creative/delete_creative.py @@ -10,14 +10,13 @@ log = logging.getLogger(__name__) async def delete_creative(self: 'Usecase', input: dto.DeleteCreativeInput) -> None: - async with self.database.transaction(): - creative = await self.database.get_creative(input.user_id, input.creative_id) - if not creative: - log.warning('User %s attempted to delete unavailable creative %s', input.user_id, input.creative_id) - raise domain.CreativeNotFound(input.creative_id) + creative = await self.database.get_creative(input.user_id, input.creative_id) + if not creative: + log.warning('User %s attempted to delete unavailable creative %s', input.user_id, input.creative_id) + raise domain.CreativeNotFound(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) + 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) + 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 85b285b..bc1c3aa 100644 --- a/src/usecase/creative/get_creative.py +++ b/src/usecase/creative/get_creative.py @@ -10,19 +10,18 @@ log = logging.getLogger(__name__) async def get_creative(self: 'Usecase', input: dto.GetCreativeInput) -> dto.CreativeOutput: - async with self.database.transaction(): - creative = await self.database.get_creative(input.user_id, input.creative_id) - if not creative: - log.warning('User %s attempted to access unavailable creative %s', input.user_id, input.creative_id) - raise domain.CreativeNotFound(input.creative_id) + creative = await self.database.get_creative(input.user_id, input.creative_id) + if not creative: + log.warning('User %s attempted to access unavailable creative %s', input.user_id, input.creative_id) + raise domain.CreativeNotFound(input.creative_id) - return dto.CreativeOutput( - id=creative.id, - name=creative.name, - text=creative.text, - target_channel_id=creative.target_channel_id, - target_channel_title=creative.target_channel.title, - created_at=creative.created_at, - status=creative.status, - placements_count=creative.placements_count, - ) + return dto.CreativeOutput( + id=creative.id, + name=creative.name, + text=creative.text, + target_channel_id=creative.target_channel_id, + target_channel_title=creative.target_channel.title, + created_at=creative.created_at, + status=creative.status, + placements_count=creative.placements_count, + ) diff --git a/src/usecase/creative/get_creatives.py b/src/usecase/creative/get_creatives.py index 5106d95..5fe4f5e 100644 --- a/src/usecase/creative/get_creatives.py +++ b/src/usecase/creative/get_creatives.py @@ -7,10 +7,7 @@ if TYPE_CHECKING: async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> dto.GetCreativesOutput: - async with self.database.transaction(): - creatives = await self.database.get_user_creatives( - input.user_id, target_channel_id=input.target_channel_id, include_archived=input.include_archived - ) + creatives = await self.database.get_user_creatives(input.user_id, input.target_channel_id, input.include_archived) return dto.GetCreativesOutput( creatives=[ diff --git a/src/usecase/creative/handle_creative_callback.py b/src/usecase/creative/handle_creative_callback.py index 7406091..0928f0d 100644 --- a/src/usecase/creative/handle_creative_callback.py +++ b/src/usecase/creative/handle_creative_callback.py @@ -13,43 +13,40 @@ log = logging.getLogger(__name__) async def handle_creative_callback( self: 'Usecase', telegram_id: int, chat_id: int, callback_data: str, message_id: int ) -> None: - async with self.database.transaction(): - state = await self.database.get_telegram_state(telegram_id) - if not state or state.state != domain.TelegramStateEnum.CREATIVE_WAITING_CHANNEL: - log.debug('User %s has no active creative creation flow or wrong state', telegram_id) - return + state = await self.database.get_telegram_state(telegram_id) + if not state or state.state != domain.TelegramStateEnum.CREATIVE_WAITING_CHANNEL: + log.debug('User %s has no active creative creation flow or wrong state', telegram_id) + return - if not callback_data.startswith('cc_ch:'): - log.warning('Invalid callback data format: %s', callback_data) - return + if not callback_data.startswith('cc_ch:'): + log.warning('Invalid callback data format: %s', callback_data) + return - channel_id_str = callback_data.split(':', 1)[1] - try: - channel_id = uuid.UUID(channel_id_str) - except ValueError: - log.error('Invalid channel UUID in callback data: %s', channel_id_str) - return + channel_id_str = callback_data.split(':', 1)[1] + try: + channel_id = uuid.UUID(channel_id_str) + except ValueError: + log.error('Invalid channel UUID in callback data: %s', channel_id_str) + return - user = await self.database.get_user(telegram_id=telegram_id) - if not user: - await self.telegram_bot.send_message('❌ Ошибка: пользователь не найден.', chat_id=chat_id) - await self.database.clear_telegram_state(telegram_id) - return + user = await self.database.get_user(telegram_id=telegram_id) + if not user: + await self.telegram_bot.send_message('❌ Ошибка: пользователь не найден.', chat_id) + await self.database.clear_telegram_state(telegram_id) + return - channel = await self.database.get_target_channel(user.id, channel_id=channel_id) - if not channel: - await self.telegram_bot.send_message('❌ Канал не найден или не принадлежит вам.', chat_id=chat_id) - await self.database.clear_telegram_state(telegram_id) - return + channel = await self.database.get_target_channel(user.id, channel_id=channel_id) + if not channel: + await self.telegram_bot.send_message('❌ Канал не найден или не принадлежит вам.', chat_id) + await self.database.clear_telegram_state(telegram_id) + return - await self.database.set_telegram_state( - telegram_id=telegram_id, - state=domain.TelegramStateEnum.CREATIVE_WAITING_NAME, - context={'target_channel_id': str(channel_id), 'channel_title': channel.title}, - ) + await self.database.set_telegram_state( + telegram_id, + domain.TelegramStateEnum.CREATIVE_WAITING_NAME, + {'target_channel_id': str(channel_id), 'channel_title': channel.title}, + ) - await self.telegram_bot.edit_message_reply_markup(chat_id, message_id) + await self.telegram_bot.edit_message_reply_markup(chat_id, message_id) - await self.telegram_bot.send_message( - f'✅ Канал выбран: {channel.title}\n\n📝 Введите название креатива:', chat_id=chat_id - ) + await self.telegram_bot.send_message(f'✅ Канал выбран: {channel.title}\n\n📝 Введите название креатива:', chat_id) diff --git a/src/usecase/creative/handle_creative_text_input.py b/src/usecase/creative/handle_creative_text_input.py index bfd0a8d..e45802e 100644 --- a/src/usecase/creative/handle_creative_text_input.py +++ b/src/usecase/creative/handle_creative_text_input.py @@ -13,86 +13,79 @@ log = logging.getLogger(__name__) async def handle_creative_text_input( - self: 'Usecase', - telegram_id: int, - chat_id: int, - text: str, - text_html: str | None = None, + self: 'Usecase', telegram_id: int, chat_id: int, text: str, text_html: str | None = None ) -> None: - async with self.database.transaction(): - state = await self.database.get_telegram_state(telegram_id) - if not state: - return + state = await self.database.get_telegram_state(telegram_id) + if not state: + return - user = await self.database.get_user(telegram_id=telegram_id) - if not user: - await self.telegram_bot.send_message('❌ Ошибка: пользователь не найден.', chat_id=chat_id) + user = await self.database.get_user(telegram_id=telegram_id) + if not user: + await self.telegram_bot.send_message('❌ Ошибка: пользователь не найден.', chat_id) + await self.database.clear_telegram_state(telegram_id) + return + + if state.state == domain.TelegramStateEnum.CREATIVE_WAITING_NAME: + await self.database.set_telegram_state( + telegram_id, domain.TelegramStateEnum.CREATIVE_WAITING_TEXT, {**state.context, 'creative_name': text} + ) + await self.telegram_bot.send_message( + '✅ Название сохранено!\n\n' + '💬 Теперь отправьте или перешлите текст креатива.\n' + 'Используйте {link} для обозначения места вставки ссылки на ваш канал.\n' + 'Вы также можете указать текст ссылки в формате [текст]{link}.', + chat_id, + ) + + elif state.state == domain.TelegramStateEnum.CREATIVE_WAITING_TEXT: + target_channel_id_str = state.context.get('target_channel_id') + creative_name = state.context.get('creative_name') + + if not target_channel_id_str or not creative_name: + log.error('Missing context data for creative creation: %s', state.context) + await self.telegram_bot.send_message('❌ Ошибка: данные не найдены. Начните заново.', chat_id) await self.database.clear_telegram_state(telegram_id) return - if state.state == domain.TelegramStateEnum.CREATIVE_WAITING_NAME: - await self.database.set_telegram_state( - telegram_id=telegram_id, - state=domain.TelegramStateEnum.CREATIVE_WAITING_TEXT, - context={**state.context, 'creative_name': text}, - ) - await self.telegram_bot.send_message( - '✅ Название сохранено!\n\n' - '💬 Теперь отправьте или перешлите текст креатива.\n' - 'Используйте {link} для обозначения места вставки ссылки на ваш канал.\n' - 'Вы также можете указать текст ссылки в формате [текст]{link}.', - chat_id=chat_id, - ) + try: + target_channel_id = uuid.UUID(target_channel_id_str) + except ValueError: + log.error('Invalid target_channel_id in context: %s', target_channel_id_str) + await self.telegram_bot.send_message('❌ Ошибка: неверный формат данных.', chat_id) + await self.database.clear_telegram_state(telegram_id) + return - elif state.state == domain.TelegramStateEnum.CREATIVE_WAITING_TEXT: - target_channel_id_str = state.context.get('target_channel_id') - creative_name = state.context.get('creative_name') + channel = await self.database.get_target_channel(user.id, channel_id=target_channel_id) + if not channel: + await self.telegram_bot.send_message('❌ Канал не найден.', chat_id) + await self.database.clear_telegram_state(telegram_id) + return - if not target_channel_id_str or not creative_name: - log.error('Missing context data for creative creation: %s', state.context) - await self.telegram_bot.send_message('❌ Ошибка: данные не найдены. Начните заново.', chat_id=chat_id) - await self.database.clear_telegram_state(telegram_id) - return + message_html = text_html or text + creative_text = prepare_creative_text_html(message_html) - try: - target_channel_id = uuid.UUID(target_channel_id_str) - except ValueError: - log.error('Invalid target_channel_id in context: %s', target_channel_id_str) - await self.telegram_bot.send_message('❌ Ошибка: неверный формат данных.', chat_id=chat_id) - await self.database.clear_telegram_state(telegram_id) - return - - channel = await self.database.get_target_channel(user.id, channel_id=target_channel_id) - if not channel: - await self.telegram_bot.send_message('❌ Канал не найден.', chat_id=chat_id) - await self.database.clear_telegram_state(telegram_id) - return - - message_html = text_html or text - creative_text = prepare_creative_text_html(message_html) - - creative = domain.Creative( - name=creative_name, - text=creative_text, - status=domain.CreativeStatus.ACTIVE, - user_id=user.id, - target_channel_id=target_channel_id, - ) - - created = await self.database.create_creative(creative) + creative = domain.Creative( + name=creative_name, + text=creative_text, + status=domain.CreativeStatus.ACTIVE, + user_id=user.id, + target_channel_id=target_channel_id, + ) + async with self.database.transaction(): + await self.database.create_creative(creative) await self.database.clear_telegram_state(telegram_id) - text_preview_source = text - text_preview = text_preview_source[:100] + '...' if len(text_preview_source) > 100 else text_preview_source - await self.telegram_bot.send_message( - f'✅ Креатив успешно создан!\n\n' - f'📝 Название: {created.name}\n' - f'📢 Канал: {channel.title}\n\n' - f'💬 Текст:\n{text_preview}', - chat_id=chat_id, - ) + text_preview_source = text + text_preview = text_preview_source[:100] + '...' if len(text_preview_source) > 100 else text_preview_source + await self.telegram_bot.send_message( + f'✅ Креатив успешно создан!\n\n' + f'📝 Название: {creative.name}\n' + f'📢 Канал: {channel.title}\n\n' + f'💬 Текст:\n{text_preview}', + chat_id, + ) - else: - log.warning('Unknown state: %s', state.state) - await self.database.clear_telegram_state(telegram_id) + else: + log.warning('Unknown state: %s', state.state) + await self.database.clear_telegram_state(telegram_id) diff --git a/src/usecase/creative/start_creative_creation.py b/src/usecase/creative/start_creative_creation.py index 43516d7..4fb20e3 100644 --- a/src/usecase/creative/start_creative_creation.py +++ b/src/usecase/creative/start_creative_creation.py @@ -12,32 +12,22 @@ log = logging.getLogger(__name__) async def start_creative_creation(self: 'Usecase', telegram_id: int, chat_id: int) -> None: - async with self.database.transaction(): - user = await self.database.get_user(telegram_id=telegram_id) - if not user: - await self.telegram_bot.send_message( - '❌ Вы не авторизованы. Используйте /start для входа.', chat_id=chat_id - ) - return + user = await self.database.get_user(telegram_id=telegram_id) + if not user: + await self.telegram_bot.send_message('❌ Вы не авторизованы. Используйте /start для входа.', chat_id) + return - channels = await self.database.get_user_target_channels(user.id) - - if not channels: - await self.telegram_bot.send_message( - '❌ У вас нет подключенных каналов. Добавьте бота в свой канал для начала работы.', chat_id=chat_id - ) - return - - buttons = [ - [InlineKeyboardButton(text=channel.title, callback_data=f'cc_ch:{channel.id}')] for channel in channels - ] - - await self.telegram_bot.send_message_with_inline_keyboard( - '✨ Создание нового креатива\n\nВыберите канал, для которого создается креатив:', - chat_id=chat_id, - buttons=buttons, + channels = await self.database.get_user_target_channels(user.id) + if not channels: + await self.telegram_bot.send_message( + '❌ У вас нет подключенных каналов. Добавьте бота в свой канал для начала работы.', chat_id ) + return - await self.database.set_telegram_state( - telegram_id=telegram_id, state=domain.TelegramStateEnum.CREATIVE_WAITING_CHANNEL, context={} - ) + buttons = [[InlineKeyboardButton(text=channel.title, callback_data=f'cc_ch:{channel.id}')] for channel in channels] + + await self.telegram_bot.send_message_with_inline_keyboard( + '✨ Создание нового креатива\n\nВыберите канал, для которого создается креатив:', chat_id, buttons + ) + + await self.database.set_telegram_state(telegram_id, domain.TelegramStateEnum.CREATIVE_WAITING_CHANNEL, context={}) diff --git a/src/usecase/creative/update_creative.py b/src/usecase/creative/update_creative.py index fad6a82..c3ca16d 100644 --- a/src/usecase/creative/update_creative.py +++ b/src/usecase/creative/update_creative.py @@ -13,28 +13,27 @@ log = logging.getLogger(__name__) async def update_creative( self: 'Usecase', creative_id: uuid.UUID, input: dto.UpdateCreativeInput, user_id: uuid.UUID ) -> dto.CreativeOutput: - async with self.database.transaction(): - creative = await self.database.get_creative(user_id, creative_id) - if not creative: - log.warning('User %s attempted to update unavailable creative %s', user_id, creative_id) - raise domain.CreativeNotFound(creative_id) + creative = await self.database.get_creative(user_id, creative_id) + if not creative: + log.warning('User %s attempted to update unavailable creative %s', user_id, creative_id) + raise domain.CreativeNotFound(creative_id) - if input.name is not None: - creative.name = input.name - if input.text is not None: - creative.text = input.text - if input.status is not None: - creative.status = input.status + if input.name: + creative.name = input.name + if input.text: + creative.text = input.text + if input.status: + creative.status = input.status - updated = await self.database.update_creative(creative) + await self.database.update_creative(creative) - return dto.CreativeOutput( - id=updated.id, - name=updated.name, - text=updated.text, - target_channel_id=updated.target_channel_id, - target_channel_title=updated.target_channel.title, - created_at=updated.created_at, - status=updated.status, - placements_count=updated.placements_count, - ) + return dto.CreativeOutput( + id=creative.id, + name=creative.name, + text=creative.text, + target_channel_id=creative.target_channel_id, + target_channel_title=creative.target_channel.title, + created_at=creative.created_at, + status=creative.status, + placements_count=creative.placements_count, + ) diff --git a/src/usecase/external_channel/create_external_channel.py b/src/usecase/external_channel/create_external_channel.py index cd68c0b..848d194 100644 --- a/src/usecase/external_channel/create_external_channel.py +++ b/src/usecase/external_channel/create_external_channel.py @@ -13,13 +13,14 @@ log = logging.getLogger(__name__) async def create_external_channel( self: 'Usecase', input: dto.CreateExternalChannelInput, user_id: uuid.UUID ) -> dto.ExternalChannelOutput: - async with self.database.transaction(): - for target_id in input.target_channel_ids: - target_channel = await self.database.get_target_channel(user_id, channel_id=target_id) - if not target_channel: - log.warning('User %s attempted to add external channel to unavailable target %s', user_id, target_id) - raise domain.TargetChannelNotFound(target_id) + for target_id in input.target_channel_ids: + target_channel = await self.database.get_target_channel(user_id, channel_id=target_id) + if not target_channel: + log.warning('User %s attempted to add external channel to unavailable target %s', user_id, target_id) + raise domain.TargetChannelNotFound(target_id) + + async with self.database.transaction(): channel = domain.ExternalChannel( telegram_id=input.telegram_id, title=input.title, @@ -28,20 +29,20 @@ async def create_external_channel( subscribers_count=input.subscribers_count, user_id=user_id, ) - created_channel = await self.database.create_external_channel(channel) + await self.database.create_external_channel(channel) if input.target_channel_ids: - await self.database.add_external_channel_to_targets(created_channel.id, input.target_channel_ids) + await self.database.add_external_channel_to_targets(channel.id, input.target_channel_ids) log.info( 'External channel %s created and linked to %s target channels', input.telegram_id, len(input.target_channel_ids) ) return dto.ExternalChannelOutput( - id=created_channel.id, - telegram_id=created_channel.telegram_id, - title=created_channel.title, - username=created_channel.username, - description=created_channel.description, - subscribers_count=created_channel.subscribers_count, + id=channel.id, + telegram_id=channel.telegram_id, + title=channel.title, + username=channel.username, + description=channel.description, + subscribers_count=channel.subscribers_count, ) diff --git a/src/usecase/external_channel/delete_external_channel.py b/src/usecase/external_channel/delete_external_channel.py index ffc7522..74be4f4 100644 --- a/src/usecase/external_channel/delete_external_channel.py +++ b/src/usecase/external_channel/delete_external_channel.py @@ -10,11 +10,10 @@ log = logging.getLogger(__name__) async def delete_external_channel(self: 'Usecase', input: dto.DeleteExternalChannelInput) -> None: - async with self.database.transaction(): - channel = await self.database.get_external_channel(input.user_id, channel_id=input.channel_id) - if not channel: - log.warning('User %s attempted to delete unavailable external channel %s', input.user_id, input.channel_id) - raise domain.ExternalChannelNotFound(input.channel_id) + channel = await self.database.get_external_channel(input.user_id, channel_id=input.channel_id) + if not channel: + log.warning('User %s attempted to delete unavailable external channel %s', input.user_id, input.channel_id) + raise domain.ExternalChannelNotFound(input.channel_id) - await self.database.delete_external_channel(input.channel_id) - log.info('User %s deleted external channel %s', input.user_id, input.channel_id) + await self.database.delete_external_channel(input.channel_id) + log.info('User %s deleted external channel %s', input.user_id, input.channel_id) diff --git a/src/usecase/external_channel/get_external_channels.py b/src/usecase/external_channel/get_external_channels.py index 571d45e..d2f3530 100644 --- a/src/usecase/external_channel/get_external_channels.py +++ b/src/usecase/external_channel/get_external_channels.py @@ -10,15 +10,12 @@ log = logging.getLogger(__name__) async def get_external_channels(self: 'Usecase', input: dto.GetExternalChannelsInput) -> dto.GetExternalChannelsOutput: - async with self.database.transaction(): - target_channel = await self.database.get_target_channel(input.user_id, channel_id=input.target_channel_id) - if not target_channel: - log.warning( - 'Target channel %s not found or not accessible for user %s', input.target_channel_id, input.user_id - ) - raise domain.TargetChannelNotFound(input.target_channel_id) + target_channel = await self.database.get_target_channel(input.user_id, channel_id=input.target_channel_id) + if not target_channel: + log.warning('Target channel %s not found or not accessible for user %s', input.target_channel_id, input.user_id) + raise domain.TargetChannelNotFound(input.target_channel_id) - channels = await self.database.get_external_channels_for_target(input.target_channel_id) + channels = await self.database.get_external_channels_for_target(input.target_channel_id) return dto.GetExternalChannelsOutput( external_channels=[ diff --git a/src/usecase/external_channel/update_external_channel.py b/src/usecase/external_channel/update_external_channel.py index 4638d27..e23a3be 100644 --- a/src/usecase/external_channel/update_external_channel.py +++ b/src/usecase/external_channel/update_external_channel.py @@ -13,30 +13,29 @@ log = logging.getLogger(__name__) async def update_external_channel( self: 'Usecase', channel_id: uuid.UUID, input: dto.UpdateExternalChannelInput, user_id: uuid.UUID ) -> dto.ExternalChannelOutput: - async with self.database.transaction(): - external_channel = await self.database.get_external_channel(user_id, channel_id=channel_id) - if not external_channel: - log.warning('External channel %s not found', channel_id) - raise domain.ExternalChannelNotFound(channel_id) + external_channel = await self.database.get_external_channel(user_id, channel_id=channel_id) + if not external_channel: + log.warning('External channel %s not found', channel_id) + raise domain.ExternalChannelNotFound(channel_id) - if input.title is not None: - external_channel.title = input.title - if input.username is not None: - external_channel.username = input.username - if input.description is not None: - external_channel.description = input.description - if input.subscribers_count is not None: - external_channel.subscribers_count = input.subscribers_count + if input.title is not None: + external_channel.title = input.title + if input.username is not None: + external_channel.username = input.username + if input.description is not None: + external_channel.description = input.description + if input.subscribers_count is not None: + external_channel.subscribers_count = input.subscribers_count - updated = await self.database.update_external_channel(external_channel) + await self.database.update_external_channel(external_channel) - log.info('External channel %s updated', channel_id) + log.info('External channel %s updated', channel_id) - return dto.ExternalChannelOutput( - id=updated.id, - telegram_id=updated.telegram_id, - title=updated.title, - username=updated.username, - description=updated.description, - subscribers_count=updated.subscribers_count, - ) + return dto.ExternalChannelOutput( + id=external_channel.id, + telegram_id=external_channel.telegram_id, + title=external_channel.title, + username=external_channel.username, + description=external_channel.description, + subscribers_count=external_channel.subscribers_count, + ) diff --git a/src/usecase/external_channel/update_external_channel_links.py b/src/usecase/external_channel/update_external_channel_links.py index 71dad69..dbf740f 100644 --- a/src/usecase/external_channel/update_external_channel_links.py +++ b/src/usecase/external_channel/update_external_channel_links.py @@ -13,32 +13,30 @@ log = logging.getLogger(__name__) async def update_external_channel_links( self: 'Usecase', channel_id: uuid.UUID, input: dto.UpdateExternalChannelLinksInput, user_id: uuid.UUID ) -> dto.ExternalChannelOutput: + external_channel = await self.database.get_external_channel(user_id, channel_id=channel_id) + if not external_channel: + log.warning('External channel %s not found', channel_id) + raise domain.ExternalChannelNotFound(channel_id) + + for target_id in input.add_target_channel_ids: + target_channel = await self.database.get_target_channel(user_id, channel_id=target_id) + if not target_channel: + log.warning('User %s attempted to link external channel to target %s they do not own', user_id, target_id) + raise domain.TargetChannelNotFound(target_id) + async with self.database.transaction(): - external_channel = await self.database.get_external_channel(user_id, channel_id=channel_id) - if not external_channel: - log.warning('External channel %s not found', channel_id) - raise domain.ExternalChannelNotFound(channel_id) - - for target_id in input.add_target_channel_ids: - target_channel = await self.database.get_target_channel(user_id, channel_id=target_id) - if not target_channel: - log.warning( - 'User %s attempted to link external channel to target %s they do not own', user_id, target_id - ) - raise domain.TargetChannelNotFound(target_id) - if input.add_target_channel_ids: await self.database.add_external_channel_to_targets(channel_id, input.add_target_channel_ids) for target_id in input.remove_target_channel_ids: await self.database.remove_external_channel_from_target(channel_id, target_id) - log.info( - 'External channel %s links updated: %s added, %s removed', - channel_id, - len(input.add_target_channel_ids), - len(input.remove_target_channel_ids), - ) + log.info( + 'External channel %s links updated: %s added, %s removed', + channel_id, + len(input.add_target_channel_ids), + len(input.remove_target_channel_ids), + ) return dto.ExternalChannelOutput( id=external_channel.id, diff --git a/src/usecase/placement/create_placement.py b/src/usecase/placement/create_placement.py index d7de75c..d00a22c 100644 --- a/src/usecase/placement/create_placement.py +++ b/src/usecase/placement/create_placement.py @@ -11,74 +11,63 @@ log = logging.getLogger(__name__) async def create_placement(self: 'Usecase', input: dto.CreatePlacementInput, user_id: uuid.UUID) -> dto.PlacementOutput: + 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 placement for unavailable target %s', user_id, input.target_channel_id) + raise domain.TargetChannelNotFound(input.target_channel_id) + + 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 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 placement for unavailable creative %s', user_id, input.creative_id) + raise domain.CreativeNotFound(input.creative_id) + + requires_approval = input.invite_link_type == domain.InviteLinkType.APPROVAL + invite_link = await self.telegram_bot.create_chat_invite_link(target_channel.telegram_id, requires_approval) + + placement = domain.Placement( + target_channel_id=input.target_channel_id, + external_channel_id=input.external_channel_id, + creative_id=input.creative_id, + user_id=user_id, + placement_date=input.placement_date, + cost=input.cost, + comment=input.comment, + invite_link_type=input.invite_link_type, + invite_link=invite_link, + status=domain.PlacementStatus.PENDING, + ) + 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 placement for unavailable target %s', user_id, input.target_channel_id - ) - raise domain.TargetChannelNotFound(input.target_channel_id) + await self.database.create_placement(placement) - # Проверяем существование внешнего канала - 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 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 placement for unavailable creative %s', user_id, input.creative_id) - raise domain.CreativeNotFound(input.creative_id) - - # Создаём уникальную пригласительную ссылку через Telegram Bot API - requires_approval = input.invite_link_type == domain.InviteLinkType.APPROVAL - invite_link = await self.telegram_bot.create_chat_invite_link( - chat_id=target_channel.telegram_id, - requires_approval=requires_approval, - ) - - # Создаём закуп со статусом PENDING (ожидаем публикацию поста) - placement = domain.Placement( - target_channel_id=input.target_channel_id, - external_channel_id=input.external_channel_id, - creative_id=input.creative_id, - user_id=user_id, - placement_date=input.placement_date, - cost=input.cost, - comment=input.comment, - invite_link_type=input.invite_link_type, - invite_link=invite_link, - status=domain.PlacementStatus.PENDING, - ) - - created = await self.database.create_placement(placement) - - # Увеличиваем счётчик закупов у креатива creative.placements_count += 1 await self.database.update_creative(creative) - return dto.PlacementOutput( - id=created.id, - target_channel_id=created.target_channel_id, - target_channel_title=target_channel.title, - external_channel_id=created.external_channel_id, - external_channel_title=external_channel.title, - creative_id=created.creative_id, - creative_name=creative.name, - placement_date=created.placement_date, - cost=created.cost, - comment=created.comment, - ad_post_url=created.ad_post_url, - invite_link_type=created.invite_link_type, - invite_link=created.invite_link, - status=created.status, - subscriptions_count=created.subscriptions_count, - views_count=created.views_count, - views_availability=created.views_availability, - last_views_fetch_at=created.last_views_fetch_at, - created_at=created.created_at, - ) + return dto.PlacementOutput( + id=placement.id, + target_channel_id=placement.target_channel_id, + target_channel_title=target_channel.title, + external_channel_id=placement.external_channel_id, + external_channel_title=external_channel.title, + creative_id=placement.creative_id, + creative_name=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/delete_placement.py b/src/usecase/placement/delete_placement.py index 91eb8ea..040ceae 100644 --- a/src/usecase/placement/delete_placement.py +++ b/src/usecase/placement/delete_placement.py @@ -10,17 +10,19 @@ 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) + 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) + if placement.status != domain.PlacementStatus.ACTIVE: + await self.database.delete_placement(input.placement_id) + return + + async with self.database.transaction(): + 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 index 5b7999c..1abea36 100644 --- a/src/usecase/placement/get_placement.py +++ b/src/usecase/placement/get_placement.py @@ -10,30 +10,29 @@ 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) + 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, - ) + 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 index dd0cd1b..06e604c 100644 --- a/src/usecase/placement/get_placements.py +++ b/src/usecase/placement/get_placements.py @@ -7,14 +7,13 @@ if TYPE_CHECKING: 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, - ) + 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=[ diff --git a/src/usecase/placement/update_placement.py b/src/usecase/placement/update_placement.py index 789c1dd..70c9e84 100644 --- a/src/usecase/placement/update_placement.py +++ b/src/usecase/placement/update_placement.py @@ -13,42 +13,40 @@ log = logging.getLogger(__name__) async def update_placement( self: 'Usecase', placement_id: uuid.UUID, input: dto.UpdatePlacementInput, user_id: uuid.UUID ) -> dto.PlacementOutput: - async with self.database.transaction(): - 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) + 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: - placement.placement_date = input.placement_date - if input.cost is not None: - placement.cost = input.cost - if input.comment is not None: - placement.comment = input.comment - if input.status is not None: - placement.status = input.status + if input.placement_date is not None: + placement.placement_date = input.placement_date + if input.cost is not None: + placement.cost = input.cost + if input.comment is not None: + placement.comment = input.comment + if input.status is not None: + placement.status = input.status - updated = await self.database.update_placement(placement) + await self.database.update_placement(placement) - return dto.PlacementOutput( - id=updated.id, - target_channel_id=updated.target_channel_id, - target_channel_title=updated.target_channel.title, - external_channel_id=updated.external_channel_id, - external_channel_title=updated.external_channel.title, - creative_id=updated.creative_id, - creative_name=updated.creative.name, - placement_date=updated.placement_date, - cost=updated.cost, - comment=updated.comment, - ad_post_url=updated.ad_post_url, - invite_link_type=updated.invite_link_type, - invite_link=updated.invite_link, - status=updated.status, - subscriptions_count=updated.subscriptions_count, - views_count=updated.views_count, - views_availability=updated.views_availability, - last_views_fetch_at=updated.last_views_fetch_at, - created_at=updated.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/src/usecase/subscription/handle_subscription.py b/src/usecase/subscription/handle_subscription.py index 83626b2..1208854 100644 --- a/src/usecase/subscription/handle_subscription.py +++ b/src/usecase/subscription/handle_subscription.py @@ -17,44 +17,43 @@ async def handle_subscription( first_name: str | None = None, last_name: str | None = None, ) -> None: - async with self.database.transaction(): - 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 + 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 = domain.Subscriber( + telegram_id=user_telegram_id, + username=username, + first_name=first_name, + last_name=last_name, + ) + await self.database.upsert_subscriber(subscriber) + + active_subscription = await self.database.get_active_subscription_by_subscriber_and_channel( + subscriber.id, placement.target_channel.telegram_id + ) + + if active_subscription: + # Пользователь уже подписан на канал через другой placement + # Это не должно случиться (Telegram не даст подписаться дважды), + # но если случилось - логируем и игнорируем + log.warning( + 'User %s (telegram_id: %s) already has active subscription to channel %s via placement %s, ' + 'ignoring new subscription attempt via placement %s', + subscriber.id, + user_telegram_id, + placement.target_channel_id, + active_subscription.placement_id, + placement.id, ) - subscriber = await self.database.upsert_subscriber(subscriber) + return - active_subscription = await self.database.get_active_subscription_by_subscriber_and_channel( - subscriber.id, placement.target_channel.telegram_id - ) + # Проверяем, была ли раньше подписка через ЭТОТ placement (для реактивации) + existing_sub = await self.database.get_subscription_by_subscriber_and_placement(subscriber.id, placement.id) - if active_subscription: - # Пользователь уже подписан на канал через другой placement - # Это не должно случиться (Telegram не даст подписаться дважды), - # но если случилось - логируем и игнорируем - log.warning( - 'User %s (telegram_id: %s) already has active subscription to channel %s via placement %s, ' - 'ignoring new subscription attempt via placement %s', - subscriber.id, - user_telegram_id, - placement.target_channel_id, - active_subscription.placement_id, - placement.id, - ) - return - - # Проверяем, была ли раньше подписка через ЭТОТ placement (для реактивации) - existing_sub = await self.database.get_subscription_by_subscriber_and_placement(subscriber.id, placement.id) - - if existing_sub and existing_sub.status == domain.SubscriptionStatus.UNSUBSCRIBED: - # Реактивируем старую подписку через тот же placement + if existing_sub and existing_sub.status == domain.SubscriptionStatus.UNSUBSCRIBED: + async with self.database.transaction(): existing_sub.status = domain.SubscriptionStatus.ACTIVE existing_sub.unsubscribed_at = None await self.database.update_subscription(existing_sub) @@ -62,29 +61,30 @@ async def handle_subscription( placement.subscriptions_count += 1 await self.database.update_placement(placement) - log.info( - 'Subscription reactivated: subscriber %s (telegram_id: %s) resubscribed via same placement %s', - subscriber.id, - user_telegram_id, - placement.id, - ) - return - - subscription = domain.Subscription( - placement_id=placement.id, - subscriber_id=subscriber.id, - target_channel_id=placement.target_channel_id, - invite_link=invite_link, + log.info( + 'Subscription reactivated: subscriber %s (telegram_id: %s) resubscribed via same placement %s', + subscriber.id, + user_telegram_id, + placement.id, ) + return + + subscription = domain.Subscription( + placement_id=placement.id, + subscriber_id=subscriber.id, + target_channel_id=placement.target_channel_id, + invite_link=invite_link, + ) + async with self.database.transaction(): await self.database.create_subscription(subscription) placement.subscriptions_count += 1 await self.database.update_placement(placement) - log.info( - 'Subscription created: subscriber %s (telegram_id: %s) subscribed via placement %s (invite_link: %s)', - subscriber.id, - user_telegram_id, - placement.id, - invite_link, - ) + log.info( + 'Subscription created: subscriber %s (telegram_id: %s) subscribed via placement %s (invite_link: %s)', + subscriber.id, + user_telegram_id, + placement.id, + invite_link, + ) diff --git a/src/usecase/subscription/handle_unsubscription.py b/src/usecase/subscription/handle_unsubscription.py index 3268c9b..a08d709 100644 --- a/src/usecase/subscription/handle_unsubscription.py +++ b/src/usecase/subscription/handle_unsubscription.py @@ -1,7 +1,8 @@ -import datetime import logging from typing import TYPE_CHECKING +from tortoise import timezone + from src import domain if TYPE_CHECKING: @@ -11,39 +12,38 @@ log = logging.getLogger(__name__) async def handle_unsubscription(self: 'Usecase', user_telegram_id: int, channel_telegram_id: int) -> None: - async with self.database.transaction(): - subscriber = await self.database.get_subscriber(user_telegram_id) - if not subscriber: - log.warning('Subscriber not found for telegram_id: %s', user_telegram_id) - return + subscriber = await self.database.get_subscriber(user_telegram_id) + if not subscriber: + log.warning('Subscriber not found for telegram_id: %s', user_telegram_id) + return - subscriptions = await self.database.get_active_subscriptions_by_subscriber_and_channel( - subscriber.id, channel_telegram_id + subscriptions = await self.database.get_active_subscriptions_by_subscriber_and_channel( + subscriber.id, channel_telegram_id + ) + + if not subscriptions: + log.info( + 'No active subscriptions found for subscriber %s (telegram_id: %s) in channel %s', + subscriber.id, + user_telegram_id, + channel_telegram_id, ) + return - if not subscriptions: - log.info( - 'No active subscriptions found for subscriber %s (telegram_id: %s) in channel %s', - subscriber.id, - user_telegram_id, - channel_telegram_id, - ) - return - - for subscription in subscriptions: + for subscription in subscriptions: + async with self.database.transaction(): subscription.status = domain.SubscriptionStatus.UNSUBSCRIBED - subscription.unsubscribed_at = datetime.datetime.now(datetime.UTC) + subscription.unsubscribed_at = timezone.now() await self.database.update_subscription(subscription) - # Декрементируем счётчик подписок у закупа placement = subscription.placement if placement.subscriptions_count > 0: placement.subscriptions_count -= 1 await self.database.update_placement(placement) - log.info( - 'Subscription marked as unsubscribed: subscriber %s (telegram_id: %s) unsubscribed from placement %s', - subscriber.id, - user_telegram_id, - placement.id, - ) + log.info( + 'Subscription marked as unsubscribed: subscriber %s (telegram_id: %s) unsubscribed from placement %s', + subscriber.id, + user_telegram_id, + placement.id, + ) diff --git a/src/usecase/target_channel/connect_target_chan.py b/src/usecase/target_channel/connect_target_chan.py index 63c9318..11c9a10 100644 --- a/src/usecase/target_channel/connect_target_chan.py +++ b/src/usecase/target_channel/connect_target_chan.py @@ -17,7 +17,7 @@ async def connect_target_chan(self: 'Usecase', input: dto.ConnectTargetChanInput await self.telegram_bot.send_message( f'⚠️ Бот был добавлен в канал "{input.title}", но не является админом.\n\n' 'Пожалуйста, сделайте бота администратором канала.', - chat_id=input.user_telegram_id, + input.user_telegram_id, ) raise domain.ChannelNoAdminRights() @@ -37,42 +37,38 @@ async def connect_target_chan(self: 'Usecase', input: dto.ConnectTargetChanInput f'⚠️ Бот был добавлен в канал "{input.title}", но не имеет необходимых прав.\n\n' f'Отсутствующие права:\n{permissions_text}\n\n' 'Пожалуйста, предоставьте эти права боту в настройках канала.', - chat_id=input.user_telegram_id, + input.user_telegram_id, ) raise domain.ChannelNoAdminRights() - async with self.database.transaction(): - user = await self.database.get_user(telegram_id=input.user_telegram_id) - if not user: - log.warning(f'User {input.user_telegram_id} not found when trying to connect channel {input.telegram_id}') - await self.telegram_bot.send_message( - f'⚠️ Канал "{input.title}" не может быть подключен.\n\n' - 'Вы должны сначала авторизоваться в веб-панели перед подключением каналов.', - chat_id=input.user_telegram_id, - ) - raise domain.UserNotFound() - - channel = domain.TargetChannel( - telegram_id=input.telegram_id, - title=input.title, - username=input.username, - user_id=user.id, - status=domain.TargetChannelStatus.ACTIVE, + user = await self.database.get_user(telegram_id=input.user_telegram_id) + if not user: + log.warning(f'User {input.user_telegram_id} not found when trying to connect channel {input.telegram_id}') + await self.telegram_bot.send_message( + f'⚠️ Канал "{input.title}" не может быть подключен.\n\n' + 'Вы должны сначала авторизоваться в веб-панели перед подключением каналов.', + input.user_telegram_id, ) + raise domain.UserNotFound() - upserted_channel = await self.database.upsert_target_channel(channel) - log.info(f'Channel {input.telegram_id} connected/updated successfully by user {input.user_telegram_id}') - - await self.telegram_bot.send_message( - f'✅ Канал "{input.title}" успешно подключен!', - chat_id=input.user_telegram_id, + channel = domain.TargetChannel( + telegram_id=input.telegram_id, + title=input.title, + username=input.username, + user_id=user.id, + status=domain.TargetChannelStatus.ACTIVE, ) + await self.database.upsert_target_channel(channel) + log.info(f'Channel {input.telegram_id} connected/updated successfully by user {input.user_telegram_id}') + + await self.telegram_bot.send_message(f'✅ Канал "{input.title}" успешно подключен!', input.user_telegram_id) + return dto.ConnectTargetChanOutput( - id=upserted_channel.id, - telegram_id=upserted_channel.telegram_id, - title=upserted_channel.title, - username=upserted_channel.username, - status=upserted_channel.status, - is_active=upserted_channel.status == domain.TargetChannelStatus.ACTIVE, + id=channel.id, + telegram_id=channel.telegram_id, + title=channel.title, + username=channel.username, + status=channel.status, + is_active=channel.status == domain.TargetChannelStatus.ACTIVE, ) diff --git a/src/usecase/target_channel/disconnect_target_chan.py b/src/usecase/target_channel/disconnect_target_chan.py index 1ce41f5..50c88fe 100644 --- a/src/usecase/target_channel/disconnect_target_chan.py +++ b/src/usecase/target_channel/disconnect_target_chan.py @@ -10,12 +10,11 @@ log = logging.getLogger(__name__) async def disconnect_target_chan(self: 'Usecase', input: dto.DisconnectTargetChanInput) -> None: - async with self.database.transaction(): - channel = await self.database.get_target_channel(channel_id=input.channel_id, user_id=input.user_id) - if not channel: - raise domain.TargetChannelNotFound(input.channel_id) + channel = await self.database.get_target_channel(channel_id=input.channel_id, user_id=input.user_id) + if not channel: + raise domain.TargetChannelNotFound(input.channel_id) - channel.status = domain.TargetChannelStatus.INACTIVE - await self.database.update_target_channel(channel) + channel.status = domain.TargetChannelStatus.INACTIVE + await self.database.update_target_channel(channel) log.info(f'Target channel {input.channel_id} deactivated') diff --git a/src/usecase/target_channel/disconnect_target_chan_by_tg_id.py b/src/usecase/target_channel/disconnect_target_chan_by_tg_id.py index 85b781c..d1c033d 100644 --- a/src/usecase/target_channel/disconnect_target_chan_by_tg_id.py +++ b/src/usecase/target_channel/disconnect_target_chan_by_tg_id.py @@ -10,18 +10,17 @@ log = logging.getLogger(__name__) async def disconnect_target_chan_by_tg_id(self: 'Usecase', input: dto.DisconnectTargetChanByTgIdInput) -> None: - async with self.database.transaction(): - user = await self.database.get_user(telegram_id=input.user_telegram_id) - if not user: - log.warning(f'User with telegram_id {input.user_telegram_id} not found when disconnecting channel') - return + user = await self.database.get_user(telegram_id=input.user_telegram_id) + if not user: + log.warning(f'User with telegram_id {input.user_telegram_id} not found when disconnecting channel') + return - channel = await self.database.get_target_channel(user.id, telegram_id=input.telegram_id) - if not channel: - log.warning(f'Target channel {input.telegram_id} not found') - raise domain.TargetChannelNotFound() + channel = await self.database.get_target_channel(user.id, telegram_id=input.telegram_id) + if not channel: + log.warning(f'Target channel {input.telegram_id} not found') + raise domain.TargetChannelNotFound() - channel.status = domain.TargetChannelStatus.INACTIVE - await self.database.update_target_channel(channel) + channel.status = domain.TargetChannelStatus.INACTIVE + await self.database.update_target_channel(channel) log.info(f'Target channel {input.telegram_id} deactivated') diff --git a/src/usecase/target_channel/get_user_target_chans.py b/src/usecase/target_channel/get_user_target_chans.py index 0ffa092..7c3e2cc 100644 --- a/src/usecase/target_channel/get_user_target_chans.py +++ b/src/usecase/target_channel/get_user_target_chans.py @@ -7,8 +7,7 @@ if TYPE_CHECKING: async def get_user_target_chans(self: 'Usecase', input: dto.GetUserTargetChansInput) -> dto.GetUserTargetChansOutput: - async with self.database.transaction(): - channels = await self.database.get_user_target_channels(input.user_id) + channels = await self.database.get_user_target_channels(input.user_id) return dto.GetUserTargetChansOutput( target_channels=[ diff --git a/src/usecase/target_channel/update_target_chan_permissions.py b/src/usecase/target_channel/update_target_chan_permissions.py index acc5135..a71ae1c 100644 --- a/src/usecase/target_channel/update_target_chan_permissions.py +++ b/src/usecase/target_channel/update_target_chan_permissions.py @@ -22,34 +22,31 @@ async def update_target_chan_permissions(self: 'Usecase', input: dto.UpdateTarge log.warning(f'User with telegram_id {input.user_telegram_id} not found when updating channel permissions') return - async with self.database.transaction(): - channel = await self.database.get_target_channel(user.id, telegram_id=input.telegram_id) - if not channel: - log.warning(f'Target channel {input.telegram_id} not found when permissions changed') - raise domain.TargetChannelNotFound() + channel = await self.database.get_target_channel(user.id, telegram_id=input.telegram_id) + if not channel: + log.warning(f'Target channel {input.telegram_id} not found when permissions changed') + raise domain.TargetChannelNotFound() - if not missing_permissions: - if channel.status != domain.TargetChannelStatus.ACTIVE: - channel.status = domain.TargetChannelStatus.ACTIVE - await self.database.update_target_channel(channel) - - await self.telegram_bot.send_message( - f'✅ Канал "{input.chat_title}" был активирован.\n\nВсе необходимые права боту предоставлены!', - chat_id=input.user_telegram_id, - ) - log.info(f'Target channel {input.telegram_id} reactivated - all permissions granted') - return - - if channel.status == domain.TargetChannelStatus.ACTIVE: - channel.status = domain.TargetChannelStatus.INACTIVE + if not missing_permissions: + if channel.status != domain.TargetChannelStatus.ACTIVE: + channel.status = domain.TargetChannelStatus.ACTIVE await self.database.update_target_channel(channel) - missed_permissions = '\n'.join(f'• {p}' for p in missing_permissions) await self.telegram_bot.send_message( - f'⚠️ Канал "{input.chat_title}" был деактивирован.\ - \n\nБоту убрали необходимые права:\n{missed_permissions}', - chat_id=input.user_telegram_id, - ) - log.warning( - f'Target channel {input.telegram_id} deactivated due to missing permissions: {missing_permissions}' + f'✅ Канал "{input.chat_title}" был активирован.\n\nВсе необходимые права боту предоставлены!', + input.user_telegram_id, ) + log.info(f'Target channel {input.telegram_id} reactivated - all permissions granted') + return + + if channel.status == domain.TargetChannelStatus.ACTIVE: + channel.status = domain.TargetChannelStatus.INACTIVE + await self.database.update_target_channel(channel) + + missed_permissions = '\n'.join(f'• {p}' for p in missing_permissions) + await self.telegram_bot.send_message( + f'⚠️ Канал "{input.chat_title}" был деактивирован.\ + \n\nБоту убрали необходимые права:\n{missed_permissions}', + input.user_telegram_id, + ) + log.warning(f'Target channel {input.telegram_id} deactivated due to missing permissions: {missing_permissions}') diff --git a/src/usecase/views/get_views_history.py b/src/usecase/views/get_views_history.py index 06c7a02..2aaf8d3 100644 --- a/src/usecase/views/get_views_history.py +++ b/src/usecase/views/get_views_history.py @@ -10,18 +10,16 @@ log = logging.getLogger(__name__) async def get_views_history(self: 'Usecase', input: dto.GetViewsHistoryInput) -> dto.GetViewsHistoryOutput: - """Получить историю просмотров для закупа.""" - 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) + 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) - histories = await self.database.get_views_history( - input.placement_id, - from_date=input.from_date, - to_date=input.to_date, - ) + histories = await self.database.get_views_history( + input.placement_id, + from_date=input.from_date, + to_date=input.to_date, + ) return dto.GetViewsHistoryOutput( histories=[ diff --git a/src/usecase/views/update_views_manually.py b/src/usecase/views/update_views_manually.py index 87988be..93f16fa 100644 --- a/src/usecase/views/update_views_manually.py +++ b/src/usecase/views/update_views_manually.py @@ -1,7 +1,8 @@ -import datetime import logging from typing import TYPE_CHECKING +from tortoise import timezone + from src import domain, dto if TYPE_CHECKING: @@ -11,14 +12,14 @@ log = logging.getLogger(__name__) async def update_views_manually(self: 'Usecase', input: dto.UpdateViewsManuallyInput) -> dto.PlacementOutput: + 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 = timezone.now() + 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) - - fetched_at = datetime.datetime.now(datetime.UTC) - placement.views_count = input.views_count placement.views_availability = domain.PostViewsAvailability.MANUAL placement.last_views_fetch_at = fetched_at @@ -31,26 +32,26 @@ async def update_views_manually(self: 'Usecase', input: dto.UpdateViewsManuallyI ) await self.database.create_placement_views_history(history) - log.info('Views manually updated for placement %s: %d', placement.id, input.views_count) + log.info('Views manually updated for placement %s: %d', placement.id, input.views_count) - 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, - ) + 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 deleted file mode 100644 index db67de3..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,430 +0,0 @@ -import datetime -import uuid -from contextlib import AbstractAsyncContextManager -from typing import Any -from unittest.mock import AsyncMock - -import pytest - -from src import domain, usecase - - -class MockDatabase: - def __init__(self) -> None: - self.users: dict[uuid.UUID, domain.User] = {} - self.users_by_telegram_id: dict[int, domain.User] = {} - self.login_tokens: dict[str, domain.LoginToken] = {} - self.target_channels: dict[uuid.UUID, domain.TargetChannel] = {} - 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.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.placement_views_histories: dict[uuid.UUID, domain.PlacementViewsHistory] = {} - self.m2m_target_external: dict[uuid.UUID, list[uuid.UUID]] = {} - - def transaction(self) -> AbstractAsyncContextManager[None]: - return AsyncMock() - - async def get_user(self, *, user_id: uuid.UUID | None = None, telegram_id: int | None = None) -> domain.User | None: - if user_id: - return self.users.get(user_id) - if telegram_id: - return self.users_by_telegram_id.get(telegram_id) - return None - - async def create_user(self, user: domain.User) -> domain.User: - if not user.id: - user.id = uuid.uuid4() - if not hasattr(user, 'created_at') or user.created_at is None: - user.created_at = datetime.datetime.now(datetime.UTC) - if not hasattr(user, 'updated_at') or user.updated_at is None: - user.updated_at = datetime.datetime.now(datetime.UTC) - self.users[user.id] = user - self.users_by_telegram_id[user.telegram_id] = user - return user - - async def create_login_token( - self, user_id: uuid.UUID, token: str, expires_at: datetime.datetime - ) -> domain.LoginToken: - login_token = domain.LoginToken(user_id=user_id, token=token, expires_at=expires_at, used_at=None) - if not login_token.id: - login_token.id = uuid.uuid4() - if not hasattr(login_token, 'created_at') or login_token.created_at is None: - login_token.created_at = datetime.datetime.now(datetime.UTC) - if not hasattr(login_token, 'updated_at') or login_token.updated_at is None: - login_token.updated_at = datetime.datetime.now(datetime.UTC) - self.login_tokens[token] = login_token - return login_token - - async def get_login_token(self, token: str) -> domain.LoginToken | None: - return self.login_tokens.get(token) - - async def mark_token_as_used(self, token: str) -> None: - if token in self.login_tokens: - self.login_tokens[token].used_at = datetime.datetime.now(datetime.UTC) - - async def get_target_channel( - self, user_id: uuid.UUID, channel_id: uuid.UUID | None = None, telegram_id: int | None = None - ) -> domain.TargetChannel | None: - if channel_id: - channel = self.target_channels.get(channel_id) - if channel and channel.user_id == user_id: - return channel - if telegram_id: - channel = self.target_channels_by_telegram_id.get(telegram_id) - if channel and channel.user_id == user_id: - return channel - return None - - async def upsert_target_channel(self, channel: domain.TargetChannel) -> domain.TargetChannel: - existing = self.target_channels_by_telegram_id.get(channel.telegram_id) - if existing: - existing.title = channel.title - existing.username = channel.username - existing.status = channel.status - return existing - if not channel.id: - channel.id = uuid.uuid4() - if not hasattr(channel, 'created_at') or channel.created_at is None: - channel.created_at = datetime.datetime.now(datetime.UTC) - if not hasattr(channel, 'updated_at') or channel.updated_at is None: - channel.updated_at = datetime.datetime.now(datetime.UTC) - self.target_channels[channel.id] = channel - self.target_channels_by_telegram_id[channel.telegram_id] = channel - return channel - - async def get_user_target_channels(self, user_id: uuid.UUID) -> list[domain.TargetChannel]: - return [c for c in self.target_channels.values() if c.user_id == user_id] - - async def update_target_channel(self, channel: domain.TargetChannel) -> domain.TargetChannel: - self.target_channels[channel.id] = channel - return channel - - async def create_external_channel(self, channel: domain.ExternalChannel) -> domain.ExternalChannel: - if not channel.id: - channel.id = uuid.uuid4() - if not hasattr(channel, 'created_at') or channel.created_at is None: - channel.created_at = datetime.datetime.now(datetime.UTC) - if not hasattr(channel, 'updated_at') or channel.updated_at is None: - channel.updated_at = datetime.datetime.now(datetime.UTC) - self.external_channels[channel.id] = channel - return channel - - async def add_external_channel_to_targets( - self, external_channel_id: uuid.UUID, target_channel_ids: list[uuid.UUID] - ) -> None: - if external_channel_id not in self.m2m_target_external: - self.m2m_target_external[external_channel_id] = [] - self.m2m_target_external[external_channel_id].extend(target_channel_ids) - - async def get_external_channels_for_target(self, target_channel_id: uuid.UUID) -> list[domain.ExternalChannel]: - result = [] - for ext_id, targets in self.m2m_target_external.items(): - if target_channel_id in targets: - ext_ch = self.external_channels.get(ext_id) - if ext_ch: - result.append(ext_ch) - return result - - async def get_external_channel( - self, user_id: uuid.UUID, channel_id: uuid.UUID | None = None, telegram_id: int | None = None - ) -> domain.ExternalChannel | None: - if channel_id: - channel = self.external_channels.get(channel_id) - if channel and channel.user_id == user_id: - return channel - if telegram_id: - for channel in self.external_channels.values(): - if channel.telegram_id == telegram_id and channel.user_id == user_id: - return channel - return None - - async def remove_external_channel_from_target( - self, external_channel_id: uuid.UUID, target_channel_id: uuid.UUID - ) -> None: - if external_channel_id in self.m2m_target_external: - targets = self.m2m_target_external[external_channel_id] - if target_channel_id in targets: - targets.remove(target_channel_id) - - async def delete_external_channel(self, channel_id: uuid.UUID) -> None: - self.external_channels.pop(channel_id, None) - self.m2m_target_external.pop(channel_id, None) - - async def create_creative(self, creative: domain.Creative) -> domain.Creative: - if not creative.id: - creative.id = uuid.uuid4() - if not hasattr(creative, 'created_at') or creative.created_at is None: - 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, '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 - if not hasattr(creative, 'status') or creative.status is None: - creative.status = domain.CreativeStatus.ACTIVE - self.creatives[creative.id] = creative - return creative - - async def get_creative(self, user_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Creative | None: - creative = self.creatives.get(creative_id) - if creative and creative.user_id == user_id: - return creative - return None - - async def update_creative(self, creative: domain.Creative) -> domain.Creative: - self.creatives[creative.id] = creative - return creative - - async def get_user_creatives( - self, - user_id: uuid.UUID, - *, - target_channel_id: uuid.UUID | None = None, - include_archived: bool = False, - ) -> list[domain.Creative]: - result = [c for c in self.creatives.values() if c.user_id == user_id] - if target_channel_id: - result = [c for c in result if c.target_channel_id == target_channel_id] - if not include_archived: - result = [c for c in result if c.status == domain.CreativeStatus.ACTIVE] - return result - - async def delete_creative(self, creative_id: uuid.UUID) -> None: - self.creatives.pop(creative_id, None) - - 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(placement.target_channel_id) - if target_ch: - placement.target_channel = target_ch - ext_ch = self.external_channels.get(placement.external_channel_id) - if ext_ch: - placement.external_channel = ext_ch - creative = self.creatives.get(placement.creative_id) - if creative: - placement.creative = creative - self.placements[placement.id] = placement - return placement - - 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_placement(self, placement: domain.Placement) -> domain.Placement: - self.placements[placement.id] = placement - return placement - - async def get_user_placements( - self, - user_id: uuid.UUID, - *, - target_channel_id: uuid.UUID | None = None, - external_channel_id: uuid.UUID | None = None, - creative_id: uuid.UUID | None = None, - include_archived: bool = False, - ) -> 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: - result = [p for p in result if p.external_channel_id == external_channel_id] - 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.PlacementStatus.ACTIVE] - return sorted(result, key=lambda p: p.placement_date, reverse=True) - - 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 placement in self.placements.values(): - if placement.creative_id == creative_id and placement.status == domain.PlacementStatus.ACTIVE: - return True - return False - - 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() - if not hasattr(subscription, 'created_at') or subscription.created_at is None: - 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) - # Загружаем связанные объекты - 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_subscriber_and_placement( - self, subscriber_id: uuid.UUID, placement_id: uuid.UUID - ) -> domain.Subscription | None: - for subscription in self.subscriptions.values(): - if subscription.subscriber_id == subscriber_id and subscription.placement_id == placement_id: - return subscription - return None - - async def get_active_subscriptions_by_subscriber_and_channel( - self, subscriber_id: uuid.UUID, channel_telegram_id: int - ) -> list[domain.Subscription]: - result = [] - for subscription in self.subscriptions.values(): - if subscription.subscriber_id == subscriber_id and subscription.unsubscribed_at is None: - placement = self.placements.get(subscription.placement_id) - if placement: - target_channel = self.target_channels.get(placement.target_channel_id) - if target_channel and target_channel.telegram_id == channel_telegram_id: - subscription.placement = placement - result.append(subscription) - return result - - async def get_active_subscription_by_subscriber_and_channel( - self, subscriber_id: uuid.UUID, channel_telegram_id: int - ) -> domain.Subscription | None: - subscriptions = await self.get_active_subscriptions_by_subscriber_and_channel( - subscriber_id, channel_telegram_id - ) - return subscriptions[0] if subscriptions else None - - 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, - placement_id: uuid.UUID, - *, - from_date: datetime.datetime | None = None, - to_date: datetime.datetime | None = None, - ) -> list[domain.PlacementViewsHistory]: - result = [h for h in self.placement_views_histories.values() if h.placement_id == placement_id] - if from_date: - result = [h for h in result if h.fetched_at >= from_date] - if to_date: - result = [h for h in result if h.fetched_at <= to_date] - return sorted(result, key=lambda h: h.fetched_at) - - -class MockTelegramWriter: - def __init__(self) -> None: - self.sent_messages: list[dict[str, Any]] = [] - self.invite_link_counter = 0 - - async def send_message(self, text: str, chat_id: int) -> None: - self.sent_messages.append({'text': text, 'chat_id': chat_id, 'button': None}) - - async def send_message_with_button(self, text: str, chat_id: int, button_text: str, button_url: str) -> None: - self.sent_messages.append( - {'text': text, 'chat_id': chat_id, 'button': {'text': button_text, 'url': button_url}} - ) - - async def send_message_with_inline_keyboard(self, text: str, chat_id: int, buttons: list[list[Any]]) -> None: - self.sent_messages.append({'text': text, 'chat_id': chat_id, 'inline_keyboard': buttons}) - - async def edit_message_reply_markup(self, chat_id: int, message_id: int) -> None: - pass - - async def get_chat_member(self, chat_id: int, user_id: int) -> dict[str, Any]: - return {} - - async def create_chat_invite_link(self, chat_id: int, requires_approval: bool = False) -> str: - self.invite_link_counter += 1 - link_type = 'approval' if requires_approval else 'public' - return f'https://t.me/+{link_type}_link_{self.invite_link_counter}_{chat_id}' - - -class MockJWTEncoder: - def encode_access_token(self, user_id: uuid.UUID, telegram_id: int, username: str | None = None) -> str: - return f'jwt_token_{user_id}_{telegram_id}' - - -@pytest.fixture -def mock_database() -> MockDatabase: - return MockDatabase() - - -@pytest.fixture -def mock_telegram() -> MockTelegramWriter: - return MockTelegramWriter() - - -@pytest.fixture -def mock_jwt() -> MockJWTEncoder: - return MockJWTEncoder() - - -@pytest.fixture -def usecases( - mock_database: MockDatabase, - mock_telegram: MockTelegramWriter, - mock_jwt: MockJWTEncoder, -) -> usecase.Usecase: - return usecase.Usecase( - database=mock_database, # type: ignore[arg-type] - telegram_bot=mock_telegram, - jwt_encoder=mock_jwt, - ) diff --git a/tests/test_auth.py b/tests/test_auth.py deleted file mode 100644 index f668f25..0000000 --- a/tests/test_auth.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Тесты авторизации через Telegram.""" - -import datetime - -import pytest -from fastapi import HTTPException - -from src import domain, dto, usecase -from tests.conftest import MockDatabase, MockTelegramWriter - - -@pytest.mark.asyncio -async def test_new_user_registration(usecases: usecase.Usecase, mock_telegram: MockTelegramWriter) -> None: - """Новый пользователь регистрируется через Telegram.""" - input_data = dto.TelegramLoginInput(telegram_id=123456, username='testuser', chat_id=123456) - - await usecases.telegram_login(input_data) - - # Проверяем, что пользователь создан - user = await usecases.database.get_user(telegram_id=123456) - assert user is not None - assert user.telegram_id == 123456 - assert user.username == 'testuser' - - # Проверяем, что бот отправил сообщение с кнопкой - assert len(mock_telegram.sent_messages) == 1 - msg = mock_telegram.sent_messages[0] - assert msg['button'] is not None - assert 'token=' in msg['button']['url'] - - -@pytest.mark.asyncio -async def test_existing_user_login(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: - """Существующий пользователь получает токен для входа.""" - # Создаём пользователя - user = domain.User(telegram_id=123456, username='testuser') - await mock_database.create_user(user) - - input_data = dto.TelegramLoginInput(telegram_id=123456, username='testuser', chat_id=123456) - await usecases.telegram_login(input_data) - - # Проверяем, что не создался второй пользователь - users_count = len(mock_database.users) - assert users_count == 1 - - -@pytest.mark.asyncio -async def test_validate_login_token_success(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: - """Валидный токен обменивается на JWT.""" - user = domain.User(telegram_id=123456, username='testuser') - await mock_database.create_user(user) - - token = 'test_token_123' - expires_at = datetime.datetime.now(datetime.UTC) + datetime.timedelta(minutes=10) - await mock_database.create_login_token(user.id, token, expires_at) - - result = await usecases.validate_login_token(dto.ValidateLoginTokenInput(token=token)) - - assert result.access_token.startswith('jwt_token_') - assert str(user.id) in result.access_token - - # Проверяем, что токен помечен как использованный - login_token = await mock_database.get_login_token(token) - assert login_token is not None - assert login_token.used_at is not None - - -@pytest.mark.asyncio -async def test_validate_login_token_not_found(usecases: usecase.Usecase) -> None: - """Несуществующий токен вызывает ошибку.""" - with pytest.raises(HTTPException) as exc_info: - await usecases.validate_login_token(dto.ValidateLoginTokenInput(token='nonexistent')) - assert exc_info.value.status_code == 404 - assert 'Login token not found' in str(exc_info.value.detail) - - -@pytest.mark.asyncio -async def test_validate_login_token_already_used(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: - """Использованный токен вызывает ошибку.""" - user = domain.User(telegram_id=123456, username='testuser') - await mock_database.create_user(user) - - token = 'test_token_123' - expires_at = datetime.datetime.now(datetime.UTC) + datetime.timedelta(minutes=10) - await mock_database.create_login_token(user.id, token, expires_at) - await mock_database.mark_token_as_used(token) - - with pytest.raises(HTTPException) as exc_info: - await usecases.validate_login_token(dto.ValidateLoginTokenInput(token=token)) - assert exc_info.value.status_code == 400 - assert 'already been used' in str(exc_info.value.detail) - - -@pytest.mark.asyncio -async def test_validate_login_token_expired(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: - """Истекший токен вызывает ошибку.""" - user = domain.User(telegram_id=123456, username='testuser') - await mock_database.create_user(user) - - token = 'test_token_123' - expires_at = datetime.datetime.now(datetime.UTC) - datetime.timedelta(minutes=1) - await mock_database.create_login_token(user.id, token, expires_at) - - with pytest.raises(HTTPException) as exc_info: - await usecases.validate_login_token(dto.ValidateLoginTokenInput(token=token)) - assert exc_info.value.status_code == 400 - assert 'expired' in str(exc_info.value.detail) diff --git a/tests/test_creative_text_formatting.py b/tests/test_creative_text_formatting.py deleted file mode 100644 index e11eea7..0000000 --- a/tests/test_creative_text_formatting.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Tests for creative text formatting helpers.""" - -from src.usecase.creative.text_formatting import prepare_creative_text_html - - -def test_placeholder_is_replaced_with_custom_tag() -> None: - source = 'Подписывайтесь: {link}!' - assert prepare_creative_text_html(source) == 'Подписывайтесь: !' - - -def test_label_placeholder_is_wrapped() -> None: - source = 'Жми [сюда]{link} скорее' - assert prepare_creative_text_html(source) == 'Жми сюда скорее' - - -def test_placeholder_replacement_is_case_insensitive() -> None: - source = 'Первый {LINK}, второй {Link}.' - expected = 'Первый , второй .' - assert prepare_creative_text_html(source) == expected - - -def test_plain_html_left_untouched() -> None: - source = 'Жми и делись' - assert prepare_creative_text_html(source) == source diff --git a/tests/test_creatives.py b/tests/test_creatives.py deleted file mode 100644 index 7796cb1..0000000 --- a/tests/test_creatives.py +++ /dev/null @@ -1,244 +0,0 @@ -"""Тесты работы с креативами.""" - -import pytest -from fastapi import HTTPException - -from src import domain, dto, usecase -from tests.conftest import MockDatabase - - -@pytest.mark.asyncio -async def test_create_creative_success(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: - """Создание нового креатива.""" - user = domain.User(telegram_id=123456, username='testuser') - await mock_database.create_user(user) - - target_channel = domain.TargetChannel( - telegram_id=-1001234567890, - title='Target Channel', - username='targetchannel', - user_id=user.id, - status=domain.TargetChannelStatus.ACTIVE, - ) - await mock_database.upsert_target_channel(target_channel) - - input_data = dto.CreateCreativeInput( - name='Test Creative', - text='Приглашаю подписаться на канал: {link}', - target_channel_id=target_channel.id, - ) - - result = await usecases.create_creative(input_data, user.id) - - assert result.name == 'Test Creative' - assert result.text == 'Приглашаю подписаться на канал: {link}' - assert result.target_channel_id == target_channel.id - assert result.status == domain.CreativeStatus.ACTIVE - assert result.placements_count == 0 - - -@pytest.mark.asyncio -async def test_create_creative_invalid_target_channel(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: - """Создание креатива для несуществующего целевого канала вызывает ошибку.""" - user = domain.User(telegram_id=123456, username='testuser') - await mock_database.create_user(user) - - import uuid - - fake_target_id = uuid.uuid4() - - input_data = dto.CreateCreativeInput( - name='Test Creative', - text='Test text', - target_channel_id=fake_target_id, - ) - - with pytest.raises(HTTPException) as exc_info: - await usecases.create_creative(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_creatives(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: - """Получение списка креативов пользователя.""" - user = domain.User(telegram_id=123456, username='testuser') - await mock_database.create_user(user) - - target_channel = domain.TargetChannel( - telegram_id=-1001234567890, - title='Target Channel', - username='targetchannel', - user_id=user.id, - status=domain.TargetChannelStatus.ACTIVE, - ) - await mock_database.upsert_target_channel(target_channel) - - creative1 = domain.Creative( - name='Creative 1', - text='Text 1', - target_channel_id=target_channel.id, - user_id=user.id, - status=domain.CreativeStatus.ACTIVE, - ) - creative2 = domain.Creative( - name='Creative 2', - text='Text 2', - target_channel_id=target_channel.id, - user_id=user.id, - status=domain.CreativeStatus.ACTIVE, - ) - await mock_database.create_creative(creative1) - await mock_database.create_creative(creative2) - - result = await usecases.get_creatives(dto.GetCreativesInput(user_id=user.id)) - - assert len(result.creatives) == 2 - - -@pytest.mark.asyncio -async def test_get_creatives_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) - - target1 = domain.TargetChannel( - telegram_id=-1001234567890, - title='Target 1', - username='target1', - user_id=user.id, - status=domain.TargetChannelStatus.ACTIVE, - ) - target2 = domain.TargetChannel( - telegram_id=-1001234567891, - title='Target 2', - username='target2', - user_id=user.id, - status=domain.TargetChannelStatus.ACTIVE, - ) - await mock_database.upsert_target_channel(target1) - await mock_database.upsert_target_channel(target2) - - creative1 = domain.Creative( - name='Creative 1', - text='Text 1', - target_channel_id=target1.id, - user_id=user.id, - status=domain.CreativeStatus.ACTIVE, - ) - creative2 = domain.Creative( - name='Creative 2', - text='Text 2', - target_channel_id=target2.id, - user_id=user.id, - status=domain.CreativeStatus.ACTIVE, - ) - await mock_database.create_creative(creative1) - await mock_database.create_creative(creative2) - - result = await usecases.get_creatives(dto.GetCreativesInput(user_id=user.id, target_channel_id=target1.id)) - - assert len(result.creatives) == 1 - assert result.creatives[0].target_channel_id == target1.id - - -# TODO: Implement archive_creative method -# @pytest.mark.asyncio -# async def test_archive_creative(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: -# """Архивирование креатива.""" -# user = domain.User(telegram_id=123456, username='testuser') -# await mock_database.create_user(user) -# -# target_channel = domain.TargetChannel( -# telegram_id=-1001234567890, -# title='Target Channel', -# username='targetchannel', -# user_id=user.id, -# status=domain.TargetChannelStatus.ACTIVE, -# ) -# await mock_database.upsert_target_channel(target_channel) -# -# creative = domain.Creative( -# name='Test Creative', -# text='Test text', -# target_channel_id=target_channel.id, -# user_id=user.id, -# status=domain.CreativeStatus.ACTIVE, -# ) -# await mock_database.create_creative(creative) -# -# result = await usecases.archive_creative(creative.id, user.id) -# -# assert result.status == domain.CreativeStatus.ARCHIVED -# -# # Проверяем, что архивированный креатив не возвращается по умолчанию -# creatives_list = await usecases.get_creatives(dto.GetCreativesInput(user_id=user.id)) -# assert len(creatives_list.creatives) == 0 -# -# # Проверяем, что архивированный креатив возвращается с флагом include_archived -# creatives_with_archived = await usecases.get_creatives( -# dto.GetCreativesInput(user_id=user.id, include_archived=True) -# ) -# assert len(creatives_with_archived.creatives) == 1 - - -@pytest.mark.asyncio -async def test_update_creative(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: - """Редактирование креатива.""" - user = domain.User(telegram_id=123456, username='testuser') - await mock_database.create_user(user) - - target_channel = domain.TargetChannel( - telegram_id=-1001234567890, - title='Target Channel', - username='targetchannel', - user_id=user.id, - status=domain.TargetChannelStatus.ACTIVE, - ) - await mock_database.upsert_target_channel(target_channel) - - creative = domain.Creative( - name='Old Name', - text='Old text', - target_channel_id=target_channel.id, - user_id=user.id, - status=domain.CreativeStatus.ACTIVE, - ) - await mock_database.create_creative(creative) - - update_input = dto.UpdateCreativeInput(name='New Name', text='New text') - result = await usecases.update_creative(creative.id, update_input, user.id) - - assert result.name == 'New Name' - assert result.text == 'New text' - - -@pytest.mark.asyncio -async def test_delete_creative(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: - """Удаление креатива.""" - user = domain.User(telegram_id=123456, username='testuser') - await mock_database.create_user(user) - - target_channel = domain.TargetChannel( - telegram_id=-1001234567890, - title='Target Channel', - username='targetchannel', - user_id=user.id, - status=domain.TargetChannelStatus.ACTIVE, - ) - await mock_database.upsert_target_channel(target_channel) - - creative = domain.Creative( - name='Test Creative', - text='Test text', - target_channel_id=target_channel.id, - user_id=user.id, - status=domain.CreativeStatus.ACTIVE, - ) - await mock_database.create_creative(creative) - - await usecases.delete_creative(dto.DeleteCreativeInput(creative_id=creative.id, user_id=user.id)) - - # Проверяем, что креатив удалён - creatives_list = await usecases.get_creatives(dto.GetCreativesInput(user_id=user.id, include_archived=True)) - assert len(creatives_list.creatives) == 0 diff --git a/tests/test_external_channels.py b/tests/test_external_channels.py deleted file mode 100644 index 0d90c72..0000000 --- a/tests/test_external_channels.py +++ /dev/null @@ -1,177 +0,0 @@ -"""Тесты каталога внешних каналов.""" - -import pytest -from fastapi import HTTPException - -from src import domain, dto, usecase -from tests.conftest import MockDatabase - - -@pytest.mark.asyncio -async def test_create_external_channel_with_targets(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: - """Создание внешнего канала с привязкой к целевым каналам.""" - user = domain.User(telegram_id=123456, username='testuser') - await mock_database.create_user(user) - - target_channel = domain.TargetChannel( - telegram_id=-1001234567890, - title='Target Channel', - username='targetchannel', - user_id=user.id, - status=domain.TargetChannelStatus.ACTIVE, - ) - await mock_database.upsert_target_channel(target_channel) - - input_data = dto.CreateExternalChannelInput( - telegram_id=-1009876543210, - title='External Channel', - username='externalchannel', - description='Test external channel', - subscribers_count=10000, - target_channel_ids=[target_channel.id], - ) - - result = await usecases.create_external_channel(input_data, user.id) - - assert result.telegram_id == -1009876543210 - assert result.title == 'External Channel' - assert result.subscribers_count == 10000 - - -@pytest.mark.asyncio -async def test_create_external_channel_invalid_target(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: - """Создание внешнего канала с несуществующим целевым каналом вызывает ошибку.""" - user = domain.User(telegram_id=123456, username='testuser') - await mock_database.create_user(user) - - import uuid - - fake_target_id = uuid.uuid4() - - input_data = dto.CreateExternalChannelInput( - telegram_id=-1009876543210, - title='External Channel', - username='externalchannel', - target_channel_ids=[fake_target_id], - ) - - with pytest.raises(HTTPException) as exc_info: - await usecases.create_external_channel(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_external_channels_for_target(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: - """Получение списка внешних каналов для целевого канала.""" - user = domain.User(telegram_id=123456, username='testuser') - await mock_database.create_user(user) - - target_channel = domain.TargetChannel( - telegram_id=-1001234567890, - title='Target Channel', - username='targetchannel', - user_id=user.id, - status=domain.TargetChannelStatus.ACTIVE, - ) - await mock_database.upsert_target_channel(target_channel) - - ext_channel = domain.ExternalChannel( - telegram_id=-1009876543210, - title='External Channel', - username='externalchannel', - description=None, - subscribers_count=10000, - user_id=user.id, - ) - await mock_database.create_external_channel(ext_channel) - await mock_database.add_external_channel_to_targets(ext_channel.id, [target_channel.id]) - - result = await usecases.get_external_channels( - dto.GetExternalChannelsInput(target_channel_id=target_channel.id, user_id=user.id) - ) - - assert len(result.external_channels) == 1 - assert result.external_channels[0].telegram_id == -1009876543210 - - -@pytest.mark.asyncio -async def test_delete_external_channel(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: - """Удаление внешнего канала.""" - user = domain.User(telegram_id=123456, username='testuser') - await mock_database.create_user(user) - - target_channel = domain.TargetChannel( - telegram_id=-1001234567890, - title='Target Channel', - username='targetchannel', - user_id=user.id, - status=domain.TargetChannelStatus.ACTIVE, - ) - await mock_database.upsert_target_channel(target_channel) - - ext_channel = domain.ExternalChannel( - telegram_id=-1009876543210, - title='External Channel', - username='externalchannel', - description=None, - subscribers_count=10000, - user_id=user.id, - ) - await mock_database.create_external_channel(ext_channel) - await mock_database.add_external_channel_to_targets(ext_channel.id, [target_channel.id]) - - await usecases.delete_external_channel(dto.DeleteExternalChannelInput(channel_id=ext_channel.id, user_id=user.id)) - - # Проверяем, что канал удалён - channels = await mock_database.get_external_channels_for_target(target_channel.id) - assert len(channels) == 0 - - -@pytest.mark.asyncio -async def test_update_external_channel_links(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: - """Обновление привязок внешнего канала к целевым каналам.""" - user = domain.User(telegram_id=123456, username='testuser') - await mock_database.create_user(user) - - target1 = domain.TargetChannel( - telegram_id=-1001234567890, - title='Target 1', - username='target1', - user_id=user.id, - status=domain.TargetChannelStatus.ACTIVE, - ) - target2 = domain.TargetChannel( - telegram_id=-1001234567891, - title='Target 2', - username='target2', - user_id=user.id, - status=domain.TargetChannelStatus.ACTIVE, - ) - await mock_database.upsert_target_channel(target1) - await mock_database.upsert_target_channel(target2) - - ext_channel = domain.ExternalChannel( - telegram_id=-1009876543210, - title='External Channel', - username='externalchannel', - description=None, - subscribers_count=10000, - user_id=user.id, - ) - await mock_database.create_external_channel(ext_channel) - await mock_database.add_external_channel_to_targets(ext_channel.id, [target1.id]) - - # Добавляем связь с target2 - await usecases.update_external_channel_links( - ext_channel.id, - dto.UpdateExternalChannelLinksInput( - add_target_channel_ids=[target2.id], - remove_target_channel_ids=[], - ), - user.id, - ) - - # Проверяем, что канал теперь привязан к обоим целевым каналам - channels_for_target2 = await mock_database.get_external_channels_for_target(target2.id) - assert len(channels_for_target2) == 1 diff --git a/tests/test_placements.py b/tests/test_placements.py deleted file mode 100644 index c8020a8..0000000 --- a/tests/test_placements.py +++ /dev/null @@ -1,632 +0,0 @@ -"""Тесты работы с закупами.""" - -import datetime - -import pytest -from fastapi import HTTPException - -from src import domain, dto, usecase -from tests.conftest import MockDatabase - - -@pytest.mark.asyncio -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) - - target_channel = domain.TargetChannel( - telegram_id=-1001234567890, - title='Target Channel', - username='targetchannel', - user_id=user.id, - status=domain.TargetChannelStatus.ACTIVE, - ) - await mock_database.upsert_target_channel(target_channel) - - external_channel = domain.ExternalChannel( - telegram_id=-1009876543210, - title='External Channel', - username='externalchannel', - description='Ad channel', - subscribers_count=50000, - user_id=user.id, - ) - await mock_database.create_external_channel(external_channel) - - creative = domain.Creative( - name='Summer Sale', - text='Подписывайтесь на наш канал по ссылке: {link}', - target_channel_id=target_channel.id, - user_id=user.id, - status=domain.CreativeStatus.ACTIVE, - ) - await mock_database.create_creative(creative) - - placement_date = datetime.datetime.now(datetime.UTC) - input_data = dto.CreatePlacementInput( - target_channel_id=target_channel.id, - external_channel_id=external_channel.id, - creative_id=creative.id, - placement_date=placement_date, - cost=5000.0, - comment='Утреннее размещение', - ad_post_url='https://t.me/externalchannel/123', - invite_link_type=domain.InviteLinkType.PUBLIC, - ) - - 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 - assert result.creative_id == creative.id - assert result.cost == 5000.0 - 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.PlacementStatus.PENDING # Ожидает публикации поста - 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.placements_count == 1 - - -@pytest.mark.asyncio -async def test_create_placement_with_approval_invite_link( - usecases: usecase.Usecase, mock_database: MockDatabase -) -> None: - """Создание закупа с пригласительной ссылкой, требующей одобрения.""" - user = domain.User(telegram_id=123456, username='testuser') - await mock_database.create_user(user) - - target_channel = domain.TargetChannel( - telegram_id=-1001234567890, - title='Target Channel', - username='targetchannel', - user_id=user.id, - status=domain.TargetChannelStatus.ACTIVE, - ) - await mock_database.upsert_target_channel(target_channel) - - external_channel = domain.ExternalChannel( - telegram_id=-1009876543210, - title='External Channel', - username='externalchannel', - description=None, - subscribers_count=30000, - user_id=user.id, - ) - await mock_database.create_external_channel(external_channel) - - creative = domain.Creative( - name='Premium Creative', - text='Эксклюзивный контент: {link}', - target_channel_id=target_channel.id, - user_id=user.id, - status=domain.CreativeStatus.ACTIVE, - ) - await mock_database.create_creative(creative) - - placement_date = datetime.datetime.now(datetime.UTC) - input_data = dto.CreatePlacementInput( - target_channel_id=target_channel.id, - external_channel_id=external_channel.id, - creative_id=creative.id, - placement_date=placement_date, - invite_link_type=domain.InviteLinkType.APPROVAL, - ) - - 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_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) - - import uuid - - fake_target_id = uuid.uuid4() - fake_external_id = uuid.uuid4() - fake_creative_id = uuid.uuid4() - - input_data = dto.CreatePlacementInput( - target_channel_id=fake_target_id, - external_channel_id=fake_external_id, - creative_id=fake_creative_id, - placement_date=datetime.datetime.now(datetime.UTC), - ) - - with pytest.raises(HTTPException) as exc_info: - 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_placements(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: - """Получение списка закупов пользователя.""" - user = domain.User(telegram_id=123456, username='testuser') - await mock_database.create_user(user) - - target_channel = domain.TargetChannel( - telegram_id=-1001234567890, - title='Target Channel', - username='targetchannel', - user_id=user.id, - status=domain.TargetChannelStatus.ACTIVE, - ) - await mock_database.upsert_target_channel(target_channel) - - external_channel = domain.ExternalChannel( - telegram_id=-1009876543210, - title='External Channel', - username='externalchannel', - description=None, - subscribers_count=20000, - user_id=user.id, - ) - await mock_database.create_external_channel(external_channel) - - creative = domain.Creative( - name='Test Creative', - text='Test text', - target_channel_id=target_channel.id, - user_id=user.id, - status=domain.CreativeStatus.ACTIVE, - ) - await mock_database.create_creative(creative) - - # Создаём два закупа - placement1 = domain.Placement( - target_channel_id=target_channel.id, - external_channel_id=external_channel.id, - creative_id=creative.id, - user_id=user.id, - placement_date=datetime.datetime.now(datetime.UTC), - cost=3000.0, - invite_link='https://t.me/+link1', - invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PlacementStatus.ACTIVE, - ) - placement2 = domain.Placement( - target_channel_id=target_channel.id, - external_channel_id=external_channel.id, - creative_id=creative.id, - user_id=user.id, - placement_date=datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=1), - cost=4000.0, - invite_link='https://t.me/+link2', - invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PlacementStatus.ACTIVE, - ) - await mock_database.create_placement(placement1) - await mock_database.create_placement(placement2) - - result = await usecases.get_placements(dto.GetPlacementsInput(user_id=user.id)) - - assert len(result.placements) == 2 - # Проверяем сортировку по дате размещения (новые первые) - assert result.placements[0].id == placement1.id - - -@pytest.mark.asyncio -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) - - target1 = domain.TargetChannel( - telegram_id=-1001234567890, - title='Target 1', - username='target1', - user_id=user.id, - status=domain.TargetChannelStatus.ACTIVE, - ) - target2 = domain.TargetChannel( - telegram_id=-1001234567891, - title='Target 2', - username='target2', - user_id=user.id, - status=domain.TargetChannelStatus.ACTIVE, - ) - await mock_database.upsert_target_channel(target1) - await mock_database.upsert_target_channel(target2) - - external_channel = domain.ExternalChannel( - telegram_id=-1009876543210, - title='External Channel', - username='externalchannel', - description=None, - subscribers_count=15000, - user_id=user.id, - ) - await mock_database.create_external_channel(external_channel) - - creative1 = domain.Creative( - name='Creative 1', - text='Text 1', - target_channel_id=target1.id, - user_id=user.id, - status=domain.CreativeStatus.ACTIVE, - ) - creative2 = domain.Creative( - name='Creative 2', - text='Text 2', - target_channel_id=target2.id, - user_id=user.id, - status=domain.CreativeStatus.ACTIVE, - ) - await mock_database.create_creative(creative1) - await mock_database.create_creative(creative2) - - placement1 = domain.Placement( - target_channel_id=target1.id, - external_channel_id=external_channel.id, - creative_id=creative1.id, - user_id=user.id, - placement_date=datetime.datetime.now(datetime.UTC), - invite_link='https://t.me/+link1', - invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PlacementStatus.ACTIVE, - ) - placement2 = domain.Placement( - target_channel_id=target2.id, - external_channel_id=external_channel.id, - creative_id=creative2.id, - user_id=user.id, - placement_date=datetime.datetime.now(datetime.UTC), - invite_link='https://t.me/+link2', - invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PlacementStatus.ACTIVE, - ) - await mock_database.create_placement(placement1) - await mock_database.create_placement(placement2) - - result = await usecases.get_placements(dto.GetPlacementsInput(user_id=user.id, 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_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) - - target_channel = domain.TargetChannel( - telegram_id=-1001234567890, - title='Target Channel', - username='targetchannel', - user_id=user.id, - status=domain.TargetChannelStatus.ACTIVE, - ) - await mock_database.upsert_target_channel(target_channel) - - external_channel = domain.ExternalChannel( - telegram_id=-1009876543210, - title='External Channel', - username='externalchannel', - description=None, - subscribers_count=25000, - user_id=user.id, - ) - await mock_database.create_external_channel(external_channel) - - creative1 = domain.Creative( - name='Creative A', - text='Вариант A', - target_channel_id=target_channel.id, - user_id=user.id, - status=domain.CreativeStatus.ACTIVE, - ) - creative2 = domain.Creative( - name='Creative B', - text='Вариант B', - target_channel_id=target_channel.id, - user_id=user.id, - status=domain.CreativeStatus.ACTIVE, - ) - await mock_database.create_creative(creative1) - await mock_database.create_creative(creative2) - - # Создаём закупы с разными креативами - placement1 = domain.Placement( - target_channel_id=target_channel.id, - external_channel_id=external_channel.id, - creative_id=creative1.id, - user_id=user.id, - placement_date=datetime.datetime.now(datetime.UTC), - invite_link='https://t.me/+link1', - invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PlacementStatus.ACTIVE, - ) - placement2 = domain.Placement( - target_channel_id=target_channel.id, - external_channel_id=external_channel.id, - creative_id=creative2.id, - user_id=user.id, - placement_date=datetime.datetime.now(datetime.UTC), - invite_link='https://t.me/+link2', - invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PlacementStatus.ACTIVE, - ) - await mock_database.create_placement(placement1) - await mock_database.create_placement(placement2) - - result = await usecases.get_placements(dto.GetPlacementsInput(user_id=user.id, creative_id=creative1.id)) - - assert len(result.placements) == 1 - assert result.placements[0].creative_id == creative1.id - - -# TODO: Implement archive_placement method -# @pytest.mark.asyncio -# 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) -# -# target_channel = domain.TargetChannel( -# telegram_id=-1001234567890, -# title='Target Channel', -# username='targetchannel', -# user_id=user.id, -# status=domain.TargetChannelStatus.ACTIVE, -# ) -# await mock_database.upsert_target_channel(target_channel) -# -# external_channel = domain.ExternalChannel( -# telegram_id=-1009876543210, -# title='External Channel', -# username='externalchannel', -# description=None, -# subscribers_count=18000, -# user_id=user.id, -# ) -# await mock_database.create_external_channel(external_channel) -# -# creative = domain.Creative( -# name='Test Creative', -# text='Test text', -# target_channel_id=target_channel.id, -# user_id=user.id, -# status=domain.CreativeStatus.ACTIVE, -# ) -# await mock_database.create_creative(creative) -# -# placement = domain.Placement( -# target_channel_id=target_channel.id, -# external_channel_id=external_channel.id, -# creative_id=creative.id, -# user_id=user.id, -# placement_date=datetime.datetime.now(datetime.UTC), -# invite_link='https://t.me/+link1', -# invite_link_type=domain.InviteLinkType.PUBLIC, -# status=domain.PlacementStatus.ACTIVE, -# ) -# await mock_database.create_placement(placement) -# -# result = await usecases.archive_placement(placement.id, user.id) -# -# assert result.status == domain.PlacementStatus.ARCHIVED -# -# # Проверяем, что архивированный закуп не возвращается по умолчанию -# placements_list = await usecases.get_placements(dto.GetPlacementsInput(user_id=user.id)) -# assert len(placements_list.placements) == 0 -# -# # Проверяем, что архивированный закуп возвращается с флагом include_archived -# placements_with_archived = await usecases.get_placements( -# dto.GetPlacementsInput(user_id=user.id, include_archived=True) -# ) -# assert len(placements_with_archived.placements) == 1 - - -@pytest.mark.asyncio -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) - - target_channel = domain.TargetChannel( - telegram_id=-1001234567890, - title='Target Channel', - username='targetchannel', - user_id=user.id, - status=domain.TargetChannelStatus.ACTIVE, - ) - await mock_database.upsert_target_channel(target_channel) - - external_channel = domain.ExternalChannel( - telegram_id=-1009876543210, - title='External Channel', - username='externalchannel', - description=None, - subscribers_count=22000, - user_id=user.id, - ) - await mock_database.create_external_channel(external_channel) - - creative = domain.Creative( - name='Test Creative', - text='Test text', - target_channel_id=target_channel.id, - user_id=user.id, - status=domain.CreativeStatus.ACTIVE, - ) - await mock_database.create_creative(creative) - - old_date = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=1) - placement = domain.Placement( - target_channel_id=target_channel.id, - external_channel_id=external_channel.id, - creative_id=creative.id, - user_id=user.id, - placement_date=old_date, - cost=3000.0, - comment='Старый комментарий', - invite_link='https://t.me/+link1', - invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PlacementStatus.ACTIVE, - ) - await mock_database.create_placement(placement) - - new_date = datetime.datetime.now(datetime.UTC) - update_input = dto.UpdatePlacementInput( - placement_date=new_date, - cost=3500.0, - comment='Обновлённый комментарий', - ad_post_url='https://t.me/externalchannel/456', - ) - result = await usecases.update_placement(placement.id, update_input, user.id) - - assert result.placement_date == new_date - assert result.cost == 3500.0 - assert result.comment == 'Обновлённый комментарий' - assert result.ad_post_url == 'https://t.me/externalchannel/456' - - -@pytest.mark.asyncio -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) - - target_channel = domain.TargetChannel( - telegram_id=-1001234567890, - title='Target Channel', - username='targetchannel', - user_id=user.id, - status=domain.TargetChannelStatus.ACTIVE, - ) - await mock_database.upsert_target_channel(target_channel) - - external_channel = domain.ExternalChannel( - telegram_id=-1009876543210, - title='External Channel', - username='externalchannel', - description=None, - subscribers_count=28000, - user_id=user.id, - ) - await mock_database.create_external_channel(external_channel) - - creative = domain.Creative( - name='Test Creative', - text='Test text', - target_channel_id=target_channel.id, - user_id=user.id, - status=domain.CreativeStatus.ACTIVE, - placements_count=1, - ) - await mock_database.create_creative(creative) - - placement = domain.Placement( - target_channel_id=target_channel.id, - external_channel_id=external_channel.id, - creative_id=creative.id, - user_id=user.id, - placement_date=datetime.datetime.now(datetime.UTC), - invite_link='https://t.me/+link1', - invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PlacementStatus.ACTIVE, - ) - await mock_database.create_placement(placement) - - await usecases.delete_placement(dto.DeletePlacementInput(placement_id=placement.id, user_id=user.id)) - - # Проверяем, что закуп удалён - 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.placements_count == 0 - - -@pytest.mark.asyncio -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) - - target_channel = domain.TargetChannel( - telegram_id=-1001234567890, - title='Target Channel', - username='targetchannel', - user_id=user.id, - status=domain.TargetChannelStatus.ACTIVE, - ) - await mock_database.upsert_target_channel(target_channel) - - external1 = domain.ExternalChannel( - telegram_id=-1009876543210, - title='External 1', - username='external1', - description=None, - subscribers_count=10000, - user_id=user.id, - ) - external2 = domain.ExternalChannel( - telegram_id=-1009876543211, - title='External 2', - username='external2', - description=None, - subscribers_count=20000, - user_id=user.id, - ) - await mock_database.create_external_channel(external1) - await mock_database.create_external_channel(external2) - - creative = domain.Creative( - name='Universal Creative', - text='Универсальный текст', - target_channel_id=target_channel.id, - user_id=user.id, - status=domain.CreativeStatus.ACTIVE, - ) - await mock_database.create_creative(creative) - - # Создаём два закупа с одним креативом в разных внешних каналах - 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.CreatePlacementInput( - target_channel_id=target_channel.id, - external_channel_id=external2.id, - creative_id=creative.id, - placement_date=datetime.datetime.now(datetime.UTC), - cost=4000.0, - ) - - result1 = await usecases.create_placement(input1, user.id) - result2 = await usecases.create_placement(input2, user.id) - - # Проверяем, что созданы два разных закупа - assert result1.id != result2.id - assert result1.invite_link != result2.invite_link - - # Проверяем, что счётчик креатива увеличился на 2 - updated_creative = await mock_database.get_creative(user.id, creative.id) - assert updated_creative is not None - assert updated_creative.placements_count == 2 - - # Проверяем фильтрацию по креативу - placements_for_creative = await usecases.get_placements( - dto.GetPlacementsInput(user_id=user.id, creative_id=creative.id) - ) - assert len(placements_for_creative.placements) == 2 diff --git a/tests/test_subscriptions.py b/tests/test_subscriptions.py deleted file mode 100644 index 3855140..0000000 --- a/tests/test_subscriptions.py +++ /dev/null @@ -1,350 +0,0 @@ -"""Тесты обработки подписок пользователей через invite_link.""" - -import datetime - -import pytest - -from src import domain, usecase -from tests.conftest import MockDatabase - - -@pytest.mark.asyncio -async def test_handle_subscription_success(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: - """Первая подписка пользователя по invite_link успешно создаётся.""" - user = domain.User(telegram_id=123456, username='testuser') - await mock_database.create_user(user) - - target_channel = domain.TargetChannel( - telegram_id=-1001234567890, - title='Target Channel', - username='targetchannel', - user_id=user.id, - status=domain.TargetChannelStatus.ACTIVE, - ) - await mock_database.upsert_target_channel(target_channel) - - external_channel = domain.ExternalChannel( - telegram_id=-1009876543210, - title='External Channel', - username='externalchannel', - description=None, - subscribers_count=50000, - user_id=user.id, - ) - await mock_database.create_external_channel(external_channel) - - creative = domain.Creative( - name='Test Creative', - text='Test text', - target_channel_id=target_channel.id, - user_id=user.id, - status=domain.CreativeStatus.ACTIVE, - ) - await mock_database.create_creative(creative) - - invite_link = 'https://t.me/+unique_link_123' - placement = domain.Placement( - target_channel_id=target_channel.id, - external_channel_id=external_channel.id, - creative_id=creative.id, - user_id=user.id, - placement_date=datetime.datetime.now(datetime.UTC), - invite_link=invite_link, - invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PlacementStatus.ACTIVE, - subscriptions_count=0, - ) - await mock_database.create_placement(placement) - - # Симулируем подписку пользователя - subscriber_id = 999888777 - subscriber_username = 'new_subscriber' - await usecases.handle_subscription( - user_telegram_id=subscriber_id, - username=subscriber_username, - invite_link=invite_link, - ) - - # Проверяем, что подписка создана - 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.subscriber_id == subscriber.id - assert subscription.invite_link == invite_link - - # Проверяем, что счётчик подписок у закупа увеличился - 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 -async def test_handle_subscription_duplicate_ignored(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: - """Повторная подписка того же пользователя игнорируется - не создаёт дубликат.""" - user = domain.User(telegram_id=123456, username='testuser') - await mock_database.create_user(user) - - target_channel = domain.TargetChannel( - telegram_id=-1001234567890, - title='Target Channel', - username='targetchannel', - user_id=user.id, - status=domain.TargetChannelStatus.ACTIVE, - ) - await mock_database.upsert_target_channel(target_channel) - - external_channel = domain.ExternalChannel( - telegram_id=-1009876543210, - title='External Channel', - username='externalchannel', - description=None, - subscribers_count=30000, - user_id=user.id, - ) - await mock_database.create_external_channel(external_channel) - - creative = domain.Creative( - name='Test Creative', - text='Test text', - target_channel_id=target_channel.id, - user_id=user.id, - status=domain.CreativeStatus.ACTIVE, - ) - await mock_database.create_creative(creative) - - invite_link = 'https://t.me/+unique_link_456' - placement = domain.Placement( - target_channel_id=target_channel.id, - external_channel_id=external_channel.id, - creative_id=creative.id, - user_id=user.id, - placement_date=datetime.datetime.now(datetime.UTC), - invite_link=invite_link, - invite_link_type=domain.InviteLinkType.APPROVAL, - status=domain.PlacementStatus.ACTIVE, - subscriptions_count=0, - ) - await mock_database.create_placement(placement) - - subscriber_id = 888777666 - subscriber_username = 'duplicate_user' - - # Первая подписка - await usecases.handle_subscription( - user_telegram_id=subscriber_id, - username=subscriber_username, - invite_link=invite_link, - ) - - # Проверяем счётчик - 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( - user_telegram_id=subscriber_id, - username=subscriber_username, - invite_link=invite_link, - ) - - # Проверяем, что счётчик не увеличился - 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, не увеличился - - # Проверяем, что в БД только одна подписка этого подписчика - 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 - - -@pytest.mark.asyncio -async def test_handle_subscription_invalid_invite_link(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: - """Подписка по несуществующему invite_link не создаётся.""" - fake_invite_link = 'https://t.me/+fake_link_999' - - # Пытаемся обработать подписку по несуществующей ссылке - await usecases.handle_subscription( - user_telegram_id=777666555, - username='unknown_user', - invite_link=fake_invite_link, - ) - - # Проверяем, что подписки не создано - assert len(mock_database.subscriptions) == 0 - - -@pytest.mark.asyncio -async def test_multiple_users_subscription_same_placement( - usecases: usecase.Usecase, mock_database: MockDatabase -) -> None: - """Несколько пользователей подписываются через один закуп - каждый учитывается.""" - user = domain.User(telegram_id=123456, username='testuser') - await mock_database.create_user(user) - - target_channel = domain.TargetChannel( - telegram_id=-1001234567890, - title='Target Channel', - username='targetchannel', - user_id=user.id, - status=domain.TargetChannelStatus.ACTIVE, - ) - await mock_database.upsert_target_channel(target_channel) - - external_channel = domain.ExternalChannel( - telegram_id=-1009876543210, - title='External Channel', - username='externalchannel', - description=None, - subscribers_count=40000, - user_id=user.id, - ) - await mock_database.create_external_channel(external_channel) - - creative = domain.Creative( - name='Viral Creative', - text='Viral content', - target_channel_id=target_channel.id, - user_id=user.id, - status=domain.CreativeStatus.ACTIVE, - ) - await mock_database.create_creative(creative) - - invite_link = 'https://t.me/+popular_link_789' - placement = domain.Placement( - target_channel_id=target_channel.id, - external_channel_id=external_channel.id, - creative_id=creative.id, - user_id=user.id, - placement_date=datetime.datetime.now(datetime.UTC), - invite_link=invite_link, - invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PlacementStatus.ACTIVE, - subscriptions_count=0, - ) - await mock_database.create_placement(placement) - - # Подписываются три разных пользователя - users_data = [ - (111222333, 'user_one'), - (444555666, 'user_two'), - (777888999, 'user_three'), - ] - - for telegram_id, username in users_data: - await usecases.handle_subscription( - user_telegram_id=telegram_id, - username=username, - invite_link=invite_link, - ) - - # Проверяем, что создано 3 подписки - placement_subscriptions = [s for s in mock_database.subscriptions.values() if s.placement_id == placement.id] - assert len(placement_subscriptions) == 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_placements( - usecases: usecase.Usecase, mock_database: MockDatabase -) -> None: - """Один пользователь может подписаться через разные закупы - каждая подписка учитывается отдельно.""" - user = domain.User(telegram_id=123456, username='testuser') - await mock_database.create_user(user) - - target_channel = domain.TargetChannel( - telegram_id=-1001234567890, - title='Target Channel', - username='targetchannel', - user_id=user.id, - status=domain.TargetChannelStatus.ACTIVE, - ) - await mock_database.upsert_target_channel(target_channel) - - external_channel = domain.ExternalChannel( - telegram_id=-1009876543210, - title='External Channel', - username='externalchannel', - description=None, - subscribers_count=25000, - user_id=user.id, - ) - await mock_database.create_external_channel(external_channel) - - creative = domain.Creative( - name='Test Creative', - text='Test text', - target_channel_id=target_channel.id, - user_id=user.id, - status=domain.CreativeStatus.ACTIVE, - ) - await mock_database.create_creative(creative) - - # Создаём два разных закупа с разными invite_link - placement1 = domain.Placement( - target_channel_id=target_channel.id, - external_channel_id=external_channel.id, - creative_id=creative.id, - user_id=user.id, - placement_date=datetime.datetime.now(datetime.UTC), - invite_link='https://t.me/+link_a', - invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PlacementStatus.ACTIVE, - subscriptions_count=0, - ) - placement2 = domain.Placement( - target_channel_id=target_channel.id, - external_channel_id=external_channel.id, - creative_id=creative.id, - user_id=user.id, - placement_date=datetime.datetime.now(datetime.UTC), - invite_link='https://t.me/+link_b', - invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PlacementStatus.ACTIVE, - subscriptions_count=0, - ) - await mock_database.create_placement(placement1) - await mock_database.create_placement(placement2) - - subscriber_id = 555444333 - - # Пользователь подписывается через первый закуп - await usecases.handle_subscription( - user_telegram_id=subscriber_id, - username='multi_subscriber', - invite_link=placement1.invite_link, - ) - - # Тот же пользователь "подписывается" через второй закуп - # (технически невозможно, но тестируем логику) - await usecases.handle_subscription( - user_telegram_id=subscriber_id, - username='multi_subscriber', - invite_link=placement2.invite_link, - ) - - # Проверяем, что у каждого закупа свой счётчик - updated_placement1 = await mock_database.get_placement(user.id, placement1.id) - updated_placement2 = await mock_database.get_placement(user.id, placement2.id) - - assert updated_placement1 is not None - assert updated_placement1.subscriptions_count == 1 - - assert updated_placement2 is not None - assert updated_placement2.subscriptions_count == 1 - - # Проверяем, что создано 2 подписки - 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_target_channels.py b/tests/test_target_channels.py deleted file mode 100644 index ea5a752..0000000 --- a/tests/test_target_channels.py +++ /dev/null @@ -1,169 +0,0 @@ -"""Тесты подключения целевых каналов.""" - -import pytest -from fastapi import HTTPException - -from src import domain, dto, usecase -from tests.conftest import MockDatabase, MockTelegramWriter - - -@pytest.mark.asyncio -async def test_connect_new_channel_success(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: - """Подключение нового канала с правами администратора.""" - user = domain.User(telegram_id=123456, username='testuser') - await mock_database.create_user(user) - - input_data = dto.ConnectTargetChanInput( - telegram_id=-1001234567890, - title='Test Channel', - username='testchannel', - user_telegram_id=123456, - bot_permissions=dto.ChannelBotPermissions( - is_admin=True, - can_invite_users=True, - can_restrict_members=True, - ), - ) - - result = await usecases.connect_target_chan(input_data) - - assert result.telegram_id == -1001234567890 - assert result.title == 'Test Channel' - assert result.status == domain.TargetChannelStatus.ACTIVE - assert result.is_active is True # проверка обратной совместимости - - # Проверяем, что канал сохранён - channel = await mock_database.get_target_channel(user.id, telegram_id=-1001234567890) - assert channel is not None - - -@pytest.mark.asyncio -async def test_connect_channel_without_admin_rights( - usecases: usecase.Usecase, mock_database: MockDatabase, mock_telegram: MockTelegramWriter -) -> None: - """Канал без прав администратора не подключается.""" - user = domain.User(telegram_id=123456, username='testuser') - await mock_database.create_user(user) - - input_data = dto.ConnectTargetChanInput( - telegram_id=-1001234567890, - title='Test Channel', - username='testchannel', - user_telegram_id=123456, - bot_permissions=dto.ChannelBotPermissions( - is_admin=False, - can_invite_users=False, - can_restrict_members=False, - ), - ) - - with pytest.raises(HTTPException) as exc_info: - await usecases.connect_target_chan(input_data) - - assert exc_info.value.status_code == 403 - # Проверяем, что бот отправил предупреждение - assert len(mock_telegram.sent_messages) == 1 - assert 'не является админом' in mock_telegram.sent_messages[0]['text'] - - -@pytest.mark.asyncio -async def test_connect_channel_without_required_permissions( - usecases: usecase.Usecase, mock_database: MockDatabase, mock_telegram: MockTelegramWriter -) -> None: - """Канал без необходимых прав не подключается.""" - user = domain.User(telegram_id=123456, username='testuser') - await mock_database.create_user(user) - - input_data = dto.ConnectTargetChanInput( - telegram_id=-1001234567890, - title='Test Channel', - username='testchannel', - user_telegram_id=123456, - bot_permissions=dto.ChannelBotPermissions( - is_admin=True, - can_invite_users=False, # Отсутствует необходимое право - can_restrict_members=True, - ), - ) - - with pytest.raises(HTTPException) as exc_info: - await usecases.connect_target_chan(input_data) - - assert exc_info.value.status_code == 403 - assert len(mock_telegram.sent_messages) == 1 - assert 'не имеет необходимых прав' in mock_telegram.sent_messages[0]['text'] - - -@pytest.mark.asyncio -async def test_connect_channel_user_not_found(usecases: usecase.Usecase, mock_telegram: MockTelegramWriter) -> None: - """Подключение канала неавторизованным пользователем вызывает ошибку.""" - input_data = dto.ConnectTargetChanInput( - telegram_id=-1001234567890, - title='Test Channel', - username='testchannel', - user_telegram_id=999999, # Несуществующий пользователь - bot_permissions=dto.ChannelBotPermissions( - is_admin=True, - can_invite_users=True, - can_restrict_members=True, - ), - ) - - with pytest.raises(HTTPException) as exc_info: - await usecases.connect_target_chan(input_data) - - assert exc_info.value.status_code == 404 - assert 'not found' in str(exc_info.value.detail).lower() - assert len(mock_telegram.sent_messages) == 1 - assert 'авторизоваться' in mock_telegram.sent_messages[0]['text'] - - -@pytest.mark.asyncio -async def test_get_user_target_channels(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: - """Получение списка каналов пользователя.""" - user = domain.User(telegram_id=123456, username='testuser') - await mock_database.create_user(user) - - channel1 = domain.TargetChannel( - telegram_id=-1001234567890, - title='Channel 1', - username='channel1', - user_id=user.id, - status=domain.TargetChannelStatus.ACTIVE, - ) - channel2 = domain.TargetChannel( - telegram_id=-1001234567891, - title='Channel 2', - username='channel2', - user_id=user.id, - status=domain.TargetChannelStatus.ACTIVE, - ) - await mock_database.upsert_target_channel(channel1) - await mock_database.upsert_target_channel(channel2) - - result = await usecases.get_user_target_chans(dto.GetUserTargetChansInput(user_id=user.id)) - - assert len(result.target_channels) == 2 - - -@pytest.mark.asyncio -async def test_disconnect_target_channel(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: - """Отключение целевого канала.""" - user = domain.User(telegram_id=123456, username='testuser') - await mock_database.create_user(user) - - channel = domain.TargetChannel( - telegram_id=-1001234567890, - title='Test Channel', - username='testchannel', - user_id=user.id, - status=domain.TargetChannelStatus.ACTIVE, - ) - await mock_database.upsert_target_channel(channel) - - await usecases.disconnect_target_chan(dto.DisconnectTargetChanInput(channel_id=channel.id, user_id=user.id)) - - # Проверяем, что канал стал неактивным - updated_channel = await mock_database.get_target_channel(user.id, channel_id=channel.id) - assert updated_channel is not None - assert updated_channel.status == domain.TargetChannelStatus.INACTIVE diff --git a/tests/test_views.py b/tests/test_views.py deleted file mode 100644 index 970cfa7..0000000 --- a/tests/test_views.py +++ /dev/null @@ -1,299 +0,0 @@ -"""Тесты работы с просмотрами рекламных постов.""" - -import datetime - -import pytest - -from src import domain, dto, usecase -from tests.conftest import MockDatabase - - -@pytest.mark.asyncio -async def test_update_views_manually(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: - """Ручное обновление просмотров создаёт снимок и обновляет закуп.""" - user = domain.User(telegram_id=123456, username='testuser') - await mock_database.create_user(user) - - target_channel = domain.TargetChannel( - telegram_id=-1001234567890, - title='Target Channel', - username='targetchannel', - user_id=user.id, - status=domain.TargetChannelStatus.ACTIVE, - ) - await mock_database.upsert_target_channel(target_channel) - - external_channel = domain.ExternalChannel( - telegram_id=-1009876543210, - title='External Channel', - username='externalchannel', - description=None, - subscribers_count=40000, - user_id=user.id, - ) - await mock_database.create_external_channel(external_channel) - - creative = domain.Creative( - name='Test Creative', - text='Test text', - target_channel_id=target_channel.id, - user_id=user.id, - status=domain.CreativeStatus.ACTIVE, - ) - await mock_database.create_creative(creative) - - placement = domain.Placement( - target_channel_id=target_channel.id, - external_channel_id=external_channel.id, - creative_id=creative.id, - user_id=user.id, - placement_date=datetime.datetime.now(datetime.UTC), - invite_link='https://t.me/+link3', - invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PlacementStatus.ACTIVE, - ad_post_url='https://t.me/externalchannel/456', - ) - await mock_database.create_placement(placement) - - input_data = dto.UpdateViewsManuallyInput( - placement_id=placement.id, - user_id=user.id, - views_count=15000, - ) - - result = await usecases.update_views_manually(input=input_data) - - # Проверяем возвращённый результат - assert result.views_count == 15000 - assert result.views_availability == domain.PostViewsAvailability.MANUAL - assert result.last_views_fetch_at is not None - - # Проверяем, что создана история просмотров - 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_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 -async def test_get_views_history_empty(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: - """Получение истории просмотров для закупа без снимков возвращает пустой список.""" - user = domain.User(telegram_id=123456, username='testuser') - await mock_database.create_user(user) - - target_channel = domain.TargetChannel( - telegram_id=-1001234567890, - title='Target Channel', - username='targetchannel', - user_id=user.id, - status=domain.TargetChannelStatus.ACTIVE, - ) - await mock_database.upsert_target_channel(target_channel) - - external_channel = domain.ExternalChannel( - telegram_id=-1009876543210, - title='External Channel', - username='externalchannel', - description=None, - subscribers_count=25000, - user_id=user.id, - ) - await mock_database.create_external_channel(external_channel) - - creative = domain.Creative( - name='Test Creative', - text='Test text', - target_channel_id=target_channel.id, - user_id=user.id, - status=domain.CreativeStatus.ACTIVE, - ) - await mock_database.create_creative(creative) - - placement = domain.Placement( - target_channel_id=target_channel.id, - external_channel_id=external_channel.id, - creative_id=creative.id, - user_id=user.id, - placement_date=datetime.datetime.now(datetime.UTC), - invite_link='https://t.me/+link4', - invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PlacementStatus.ACTIVE, - ) - await mock_database.create_placement(placement) - - input_data = dto.GetViewsHistoryInput( - placement_id=placement.id, - user_id=user.id, - ) - - result = await usecases.get_views_history(input=input_data) - - 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) - - target_channel = domain.TargetChannel( - telegram_id=-1001234567890, - title='Target Channel', - username='targetchannel', - user_id=user.id, - status=domain.TargetChannelStatus.ACTIVE, - ) - await mock_database.upsert_target_channel(target_channel) - - external_channel = domain.ExternalChannel( - telegram_id=-1009876543210, - title='External Channel', - username='externalchannel', - description=None, - subscribers_count=35000, - user_id=user.id, - ) - await mock_database.create_external_channel(external_channel) - - creative = domain.Creative( - name='Test Creative', - text='Test text', - target_channel_id=target_channel.id, - user_id=user.id, - status=domain.CreativeStatus.ACTIVE, - ) - await mock_database.create_creative(creative) - - placement = domain.Placement( - target_channel_id=target_channel.id, - external_channel_id=external_channel.id, - creative_id=creative.id, - user_id=user.id, - placement_date=datetime.datetime.now(datetime.UTC), - invite_link='https://t.me/+link5', - invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PlacementStatus.ACTIVE, - ) - await mock_database.create_placement(placement) - - # Создаём несколько записей истории - now = datetime.datetime.now(datetime.UTC) - history1 = domain.PlacementViewsHistory( - placement_id=placement.id, - views_count=1000, - fetched_at=now - datetime.timedelta(hours=2), - ) - history2 = domain.PlacementViewsHistory( - placement_id=placement.id, - views_count=1500, - fetched_at=now - datetime.timedelta(hours=1), - ) - history3 = domain.PlacementViewsHistory( - placement_id=placement.id, - views_count=2000, - fetched_at=now, - ) - 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( - placement_id=placement.id, - user_id=user.id, - ) - - result = await usecases.get_views_history(input=input_data) - - assert len(result.histories) == 3 - # Проверяем сортировку по времени (старые первые) - assert result.histories[0].views_count == 1000 - assert result.histories[1].views_count == 1500 - assert result.histories[2].views_count == 2000 - - -@pytest.mark.asyncio -async def test_get_views_history_with_date_filter(usecases: usecase.Usecase, mock_database: MockDatabase) -> None: - """Фильтрация истории просмотров по датам работает корректно.""" - user = domain.User(telegram_id=123456, username='testuser') - await mock_database.create_user(user) - - target_channel = domain.TargetChannel( - telegram_id=-1001234567890, - title='Target Channel', - username='targetchannel', - user_id=user.id, - status=domain.TargetChannelStatus.ACTIVE, - ) - await mock_database.upsert_target_channel(target_channel) - - external_channel = domain.ExternalChannel( - telegram_id=-1009876543210, - title='External Channel', - username='externalchannel', - description=None, - subscribers_count=28000, - user_id=user.id, - ) - await mock_database.create_external_channel(external_channel) - - creative = domain.Creative( - name='Test Creative', - text='Test text', - target_channel_id=target_channel.id, - user_id=user.id, - status=domain.CreativeStatus.ACTIVE, - ) - await mock_database.create_creative(creative) - - placement = domain.Placement( - target_channel_id=target_channel.id, - external_channel_id=external_channel.id, - creative_id=creative.id, - user_id=user.id, - placement_date=datetime.datetime.now(datetime.UTC), - invite_link='https://t.me/+link6', - invite_link_type=domain.InviteLinkType.PUBLIC, - status=domain.PlacementStatus.ACTIVE, - ) - await mock_database.create_placement(placement) - - # Создаём записи с разными датами - now = datetime.datetime.now(datetime.UTC) - history1 = domain.PlacementViewsHistory( - placement_id=placement.id, - views_count=1000, - fetched_at=now - datetime.timedelta(days=3), - ) - history2 = domain.PlacementViewsHistory( - placement_id=placement.id, - views_count=1500, - fetched_at=now - datetime.timedelta(days=1), - ) - history3 = domain.PlacementViewsHistory( - placement_id=placement.id, - views_count=2000, - fetched_at=now, - ) - 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( - placement_id=placement.id, - user_id=user.id, - from_date=now - datetime.timedelta(days=2), - ) - - result = await usecases.get_views_history(input=input_data) - - # Должны вернуться только history2 и history3 - assert len(result.histories) == 2 - assert result.histories[0].views_count == 1500 - assert result.histories[1].views_count == 2000