ref: рефакторинг usecases

This commit is contained in:
Artem Tsyrulnikov
2025-12-11 18:02:46 +03:00
parent 38f1b3feba
commit 1f80d9bdfb
47 changed files with 853 additions and 3454 deletions

View File

@@ -2,6 +2,7 @@ import datetime
import typing import typing
import uuid import uuid
from tortoise import timezone
from tortoise.transactions import in_transaction from tortoise.transactions import in_transaction
from shared.datebase_base import DatabaseBase from shared.datebase_base import DatabaseBase
@@ -12,31 +13,34 @@ class Postgres(DatabaseBase):
def transaction(self) -> typing.AsyncContextManager[None]: def transaction(self) -> typing.AsyncContextManager[None]:
return in_transaction() 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: async def get_user(self, user_id: uuid.UUID | None = None, telegram_id: int | None = None) -> domain.User | None:
if user_id: if user_id:
return await domain.User.get_or_none(id=user_id) return await domain.User.get_or_none(id=user_id)
elif telegram_id: elif telegram_id:
return await domain.User.get_or_none(telegram_id=telegram_id) return await domain.User.get_or_none(telegram_id=telegram_id)
raise ValueError('Either user_id or telegram_id must be provided') 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: async def get_login_token(self, token: str) -> domain.LoginToken | None:
return await domain.LoginToken.get_or_none(token=token) return await domain.LoginToken.get_or_none(token=token)
async def mark_token_as_used(self, token: str) -> None: async def mark_token_as_used(self, token: str) -> None:
login_token = await domain.LoginToken.get_or_none(token=token) updated = await domain.LoginToken.filter(token=token, used_at__isnull=True).update(used_at=timezone.now())
if login_token:
login_token.used_at = datetime.datetime.now(datetime.UTC) if updated == 0:
await login_token.save() raise domain.LoginTokenAlreadyUsed() # либо нет токена, либо он уже использован
async def get_target_channel( async def get_target_channel(
self, user_id: uuid.UUID, channel_id: uuid.UUID | None = None, telegram_id: int | None = None 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: if not channel_id and not telegram_id:
raise ValueError('Either channel_id or telegram_id must be provided') 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: if channel_id:
query = query.filter(id=channel_id) q = q.filter(id=channel_id)
if telegram_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) existing = await self.get_target_channel(user_id=channel.user_id, telegram_id=channel.telegram_id)
if not existing: if not existing:
await channel.save() await channel.save()
return channel return
existing.title = channel.title existing.title = channel.title
existing.username = channel.username existing.username = channel.username
existing.user_id = channel.user_id existing.user_id = channel.user_id
existing.status = channel.status existing.status = channel.status
existing.deleted_at = None existing.deleted_at = None
existing.updated_at = datetime.datetime.now(datetime.UTC) existing.updated_at = timezone.now()
await existing.save() await existing.save()
return existing
async def get_user_target_channels(self, user_id: uuid.UUID) -> list[domain.TargetChannel]: 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() 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() await channel.save()
return channel
async def get_external_channel( async def get_external_channel(
self, user_id: uuid.UUID, channel_id: uuid.UUID | None = None, telegram_id: int | None = None 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() 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]: async def get_external_channels_for_target(self, target_channel_id: uuid.UUID) -> list[domain.ExternalChannel]:
target = await ( target = await domain.TargetChannel.filter(id=target_channel_id).prefetch_related('external_channels').first()
domain.TargetChannel.filter(id=target_channel_id).prefetch_related('external_channels').first()
)
if not target: if not target:
return [] return []
return await target.external_channels.all() return await target.external_channels.all()
@@ -129,22 +125,14 @@ class Postgres(DatabaseBase):
async def delete_external_channel(self, channel_id: uuid.UUID) -> None: async def delete_external_channel(self, channel_id: uuid.UUID) -> None:
await domain.ExternalChannel.filter(id=channel_id).delete() 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() await channel.save()
return channel
async def get_creative(self, user_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Creative | None: 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') 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.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( async def get_user_creatives(
self, user_id: uuid.UUID, target_channel_id: uuid.UUID | None = None, include_archived: bool = False 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' '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.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.save()
await placement.fetch_related('target_channel', 'external_channel', 'creative')
return placement
async def get_user_placements( async def get_user_placements(
self, self,
@@ -211,78 +195,30 @@ class Postgres(DatabaseBase):
'target_channel', 'external_channel', 'creative' '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: async def get_subscriber(self, telegram_id: int) -> domain.Subscriber | None:
return await domain.Subscriber.get_or_none(telegram_id=telegram_id) return await domain.Subscriber.get_or_none(telegram_id=telegram_id)
async def create_subscriber(self, subscriber: domain.Subscriber) -> domain.Subscriber: async def upsert_subscriber(self, subscriber: domain.Subscriber) -> None:
await subscriber.save()
return subscriber
async def upsert_subscriber(self, subscriber: domain.Subscriber) -> domain.Subscriber:
existing = await self.get_subscriber(subscriber.telegram_id) existing = await self.get_subscriber(subscriber.telegram_id)
if existing: if not existing:
# Обновляем данные если изменились await subscriber.save()
existing.username = subscriber.username return
existing.first_name = subscriber.first_name
existing.last_name = subscriber.last_name
await existing.save()
return existing
return await self.create_subscriber(subscriber)
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.save()
await subscription.fetch_related('placement', 'subscriber')
return subscription
async def get_subscription_by_subscriber_and_placement( async def get_subscription_by_subscriber_and_placement(
self, subscriber_id: uuid.UUID, placement_id: uuid.UUID self, subscriber_id: uuid.UUID, placement_id: uuid.UUID
) -> domain.Subscription | None: ) -> domain.Subscription | None:
return await domain.Subscription.get_or_none(subscriber_id=subscriber_id, placement_id=placement_id) 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.save()
await subscription.fetch_related('placement', 'subscriber')
return subscription
async def get_active_subscriptions_by_subscriber_and_channel( async def get_active_subscriptions_by_subscriber_and_channel(
self, subscriber_id: uuid.UUID, channel_telegram_id: int self, subscriber_id: uuid.UUID, channel_telegram_id: int
@@ -310,12 +246,8 @@ class Postgres(DatabaseBase):
.first() .first()
) )
async def create_placement_views_history( async def create_placement_views_history(self, history: domain.PlacementViewsHistory) -> None:
self, history: domain.PlacementViewsHistory
) -> domain.PlacementViewsHistory:
await history.save() await history.save()
await history.fetch_related('placement')
return history
async def get_views_history( async def get_views_history(
self, self,
@@ -344,7 +276,7 @@ class Postgres(DatabaseBase):
if existing: if existing:
existing.state = state existing.state = state
existing.context = context existing.context = context
existing.updated_at = datetime.datetime.now(datetime.UTC) existing.updated_at = timezone.now()
await existing.save() await existing.save()
return existing return existing

View File

@@ -1,5 +1,4 @@
import logging import logging
from typing import Any
from aiogram.types import BotCommand, InlineKeyboardButton, InlineKeyboardMarkup 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)]]) 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) 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: 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) invite_link = await self.bot.create_chat_invite_link(chat_id=chat_id, creates_join_request=requires_approval)
return invite_link.invite_link return invite_link.invite_link

View File

@@ -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 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( async def create_login_token(self, login_token: domain.LoginToken) -> None: ...
self, user_id: UUID, token: str, expires_at: datetime.datetime
) -> domain.LoginToken: ... async def create_creative(self, creative: domain.Creative) -> None: ...
async def get_login_token(self, token: str) -> domain.LoginToken | 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 self, user_id: UUID, channel_id: UUID | None = None, telegram_id: int | None = None
) -> domain.TargetChannel | 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 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( async def get_external_channel(
self, user_id: UUID, channel_id: UUID | None = None, telegram_id: int | None = None self, user_id: UUID, channel_id: UUID | None = None, telegram_id: int | None = None
) -> domain.ExternalChannel | 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]: ... 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 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 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) -> None: ...
async def update_creative(self, creative: domain.Creative) -> domain.Creative: ...
async def get_user_creatives( async def get_user_creatives(
self, user_id: UUID, target_channel_id: UUID | None = None, include_archived: bool = False 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 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( async def get_user_placements(
self, self,
@@ -123,17 +121,15 @@ class Database(typing.Protocol):
async def get_subscriber(self, telegram_id: int) -> domain.Subscriber | None: ... async def get_subscriber(self, telegram_id: int) -> domain.Subscriber | None: ...
async def create_subscriber(self, subscriber: domain.Subscriber) -> domain.Subscriber: ... async def upsert_subscriber(self, subscriber: domain.Subscriber) -> None: ...
async def upsert_subscriber(self, subscriber: domain.Subscriber) -> domain.Subscriber: ... async def create_subscription(self, subscription: domain.Subscription) -> None: ...
async def create_subscription(self, subscription: domain.Subscription) -> domain.Subscription: ...
async def get_subscription_by_subscriber_and_placement( async def get_subscription_by_subscriber_and_placement(
self, subscriber_id: UUID, placement_id: UUID self, subscriber_id: UUID, placement_id: UUID
) -> domain.Subscription | None: ... ) -> 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( async def get_active_subscriptions_by_subscriber_and_channel(
self, subscriber_id: UUID, channel_telegram_id: int self, subscriber_id: UUID, channel_telegram_id: int
@@ -143,9 +139,7 @@ class Database(typing.Protocol):
self, subscriber_id: UUID, channel_telegram_id: int self, subscriber_id: UUID, channel_telegram_id: int
) -> domain.Subscription | None: ... ) -> domain.Subscription | None: ...
async def create_placement_views_history( async def create_placement_views_history(self, history: domain.PlacementViewsHistory) -> None: ...
self, history: domain.PlacementViewsHistory
) -> domain.PlacementViewsHistory: ...
async def get_views_history( async def get_views_history(
self, self,
@@ -163,17 +157,6 @@ class Database(typing.Protocol):
async def clear_telegram_state(self, telegram_id: int) -> None: ... 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): class TelegramBotWriter(typing.Protocol):
async def send_message(self, text: str, chat_id: int) -> None: ... 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 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: ... async def create_chat_invite_link(self, chat_id: int, requires_approval: bool = False) -> str: ...

View File

@@ -14,65 +14,57 @@ log = logging.getLogger(__name__)
async def get_creatives_analytics( async def get_creatives_analytics(
self: 'Usecase', input: dto.GetCreativesAnalyticsInput self: 'Usecase', input: dto.GetCreativesAnalyticsInput
) -> dto.GetCreativesAnalyticsOutput: ) -> dto.GetCreativesAnalyticsOutput:
async with self.database.transaction(): if input.target_channel_id:
if input.target_channel_id: target_channel = await self.database.get_target_channel(input.user_id, input.target_channel_id)
target_channel = await self.database.get_target_channel(input.user_id, input.target_channel_id) if not target_channel:
if not target_channel: raise domain.TargetChannelNotFound(input.target_channel_id)
raise domain.TargetChannelNotFound(input.target_channel_id)
creatives = await self.database.get_user_creatives( creatives = await self.database.get_user_creatives(input.user_id, input.target_channel_id, include_archived=False)
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( result.append(
input.user_id, input.target_channel_id, include_archived=False 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 return dto.GetCreativesAnalyticsOutput(creatives=result)
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)

View File

@@ -14,65 +14,60 @@ log = logging.getLogger(__name__)
async def get_external_channels_analytics( async def get_external_channels_analytics(
self: 'Usecase', input: dto.GetExternalChannelsAnalyticsInput self: 'Usecase', input: dto.GetExternalChannelsAnalyticsInput
) -> dto.GetExternalChannelsAnalyticsOutput: ) -> dto.GetExternalChannelsAnalyticsOutput:
async with self.database.transaction(): if input.target_channel_id:
if input.target_channel_id: target_channel = await self.database.get_target_channel(input.user_id, input.target_channel_id)
target_channel = await self.database.get_target_channel(input.user_id, input.target_channel_id) if not target_channel:
if not target_channel: raise domain.TargetChannelNotFound(input.target_channel_id)
raise domain.TargetChannelNotFound(input.target_channel_id) external_channels = await self.database.get_external_channels_for_target(input.target_channel_id)
external_channels = await self.database.get_external_channels_for_target(input.target_channel_id) else:
else: external_channels = await self.database.get_user_external_channels(input.user_id)
external_channels = await self.database.get_user_external_channels(input.user_id)
placements = await self.database.get_user_placements( placements = await self.database.get_user_placements(input.user_id, input.target_channel_id, include_archived=False)
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 result.append(
class ChannelStats: dto.ExternalChannelAnalyticsOutput(
total_cost: float = 0.0 id=ch.id,
total_subscriptions: int = 0 title=ch.title,
total_views: int = 0 username=ch.username,
placements_count: int = 0 placements_count=stats.placements_count,
total_cost=stats.total_cost,
channel_stats: dict[UUID, ChannelStats] = {ch.id: ChannelStats() for ch in external_channels} total_subscriptions=stats.total_subscriptions,
total_views=stats.total_views,
for p in placements: avg_cpf=avg_cpf,
stats = channel_stats.get(p.external_channel_id) avg_cpm=avg_cpm,
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( return dto.GetExternalChannelsAnalyticsOutput(external_channels=result)
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)

View File

@@ -24,33 +24,30 @@ def _calculate_cpm(cost: float | None, views: int | None) -> float | None:
async def get_placements_analytics( async def get_placements_analytics(
self: 'Usecase', input: dto.GetPlacementsAnalyticsInput self: 'Usecase', input: dto.GetPlacementsAnalyticsInput
) -> dto.GetPlacementsAnalyticsOutput: ) -> dto.GetPlacementsAnalyticsOutput:
async with self.database.transaction(): if input.target_channel_id:
if input.target_channel_id: target_channel = await self.database.get_target_channel(input.user_id, input.target_channel_id)
target_channel = await self.database.get_target_channel(input.user_id, input.target_channel_id) if not target_channel:
if not target_channel: raise domain.TargetChannelNotFound(input.target_channel_id)
raise domain.TargetChannelNotFound(input.target_channel_id)
placements = await self.database.get_user_placements( placements = await self.database.get_user_placements(input.user_id, input.target_channel_id, include_archived=False)
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 = [ return dto.GetPlacementsAnalyticsOutput(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)

View File

@@ -33,82 +33,79 @@ def _format_period(dt: 'datetime.datetime', grouping: dto.DateGrouping) -> str:
async def get_spending_analytics( async def get_spending_analytics(
self: 'Usecase', input: dto.GetSpendingAnalyticsInput self: 'Usecase', input: dto.GetSpendingAnalyticsInput
) -> dto.GetSpendingAnalyticsOutput: ) -> dto.GetSpendingAnalyticsOutput:
async with self.database.transaction(): if input.target_channel_id:
if input.target_channel_id: target_channel = await self.database.get_target_channel(input.user_id, input.target_channel_id)
target_channel = await self.database.get_target_channel(input.user_id, input.target_channel_id) if not target_channel:
if not target_channel: raise domain.TargetChannelNotFound(input.target_channel_id)
raise domain.TargetChannelNotFound(input.target_channel_id)
placements = await self.database.get_user_placements( placements = await self.database.get_user_placements(input.user_id, input.target_channel_id, include_archived=False)
input.user_id, input.target_channel_id, include_archived=False
)
filtered = [ filtered = [
p p
for p in placements for p in placements
if (not input.date_from or p.placement_date >= input.date_from) if (not input.date_from or p.placement_date >= input.date_from)
and (not input.date_to or p.placement_date <= input.date_to) and (not input.date_to or p.placement_date <= input.date_to)
] ]
total_cost = 0.0 total_cost = 0.0
total_subs = 0 total_subs = 0
total_views = 0 total_views = 0
@dataclass @dataclass
class PeriodData: class PeriodData:
cost: float = 0.0 cost: float = 0.0
subscriptions: int = 0 subscriptions: int = 0
views: int = 0 views: int = 0
# Группировка по периодам # Группировка по периодам
period_data: dict[str, PeriodData] = defaultdict(PeriodData) period_data: dict[str, PeriodData] = defaultdict(PeriodData)
for p in filtered: for p in filtered:
period = _format_period(p.placement_date, input.grouping) period = _format_period(p.placement_date, input.grouping)
pd = period_data[period] pd = period_data[period]
if p.cost is not None: if p.cost is not None:
total_cost += p.cost total_cost += p.cost
pd.cost += p.cost pd.cost += p.cost
total_subs += p.subscriptions_count total_subs += p.subscriptions_count
pd.subscriptions += p.subscriptions_count pd.subscriptions += p.subscriptions_count
if p.views_count is not None: if p.views_count is not None:
total_views += p.views_count total_views += p.views_count
pd.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_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_cpm = (total_cost / total_views * 1000) if total_views > 0 and total_cost > 0 else None
# Данные для графика # Данные для графика
chart_data = [] chart_data = []
for period in sorted(period_data.keys()): for period in sorted(period_data.keys()):
d = period_data[period] d = period_data[period]
cost = d.cost cost = d.cost
subs = d.subscriptions subs = d.subscriptions
views = d.views views = d.views
cpf = cost / subs if subs > 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 cpm = (cost / views * 1000) if views > 0 and cost > 0 else None
chart_data.append( chart_data.append(
dto.SpendingDataPoint( dto.SpendingDataPoint(
period=period, period=period,
cost=cost, cost=cost,
subscriptions=subs, subscriptions=subs,
views=views, views=views,
cpf=cpf, cpf=cpf,
cpm=cpm, 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,
)

View File

@@ -8,14 +8,13 @@ if typing.TYPE_CHECKING:
async def get_me(self: 'Usecase', user_id: uuid.UUID) -> dto.UserOutput: 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: if not user:
raise domain.UserNotFound(user_id) raise domain.UserNotFound(user_id)
return dto.UserOutput( return dto.UserOutput(
id=user.id, id=user.id,
telegram_id=user.telegram_id, telegram_id=user.telegram_id,
username=user.username, username=user.username,
) )

View File

@@ -2,6 +2,8 @@ import datetime
import secrets import secrets
import typing import typing
from tortoise import timezone
from src import domain, dto from src import domain, dto
from src.config import settings from src.config import settings
@@ -10,27 +12,29 @@ if typing.TYPE_CHECKING:
async def telegram_login(self: 'Usecase', input: dto.TelegramLoginInput) -> None: async def telegram_login(self: 'Usecase', input: dto.TelegramLoginInput) -> None:
async with self.database.transaction(): user = await self.database.get_user(telegram_id=input.telegram_id)
user: domain.User | None = 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: token = secrets.token_urlsafe(32)
user = domain.User( expires_at = timezone.now() + datetime.timedelta(minutes=10)
telegram_id=input.telegram_id,
username=input.username,
)
user = await self.database.create_user(user)
token = secrets.token_urlsafe(32) login_token = domain.LoginToken(
expires_at = datetime.datetime.now(datetime.UTC) + datetime.timedelta(minutes=10) 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( await self.telegram_bot.send_message_with_button(
f'Привет, {username_display}!\n\nНажми кнопку ниже для входа на сайт.', f'Привет, {user.username or "аноним"}!\n\nНажми кнопку ниже для входа на сайт.',
chat_id=input.chat_id, input.chat_id,
button_text='Войти на сайт', button_text='Войти на сайт',
button_url=login_url, button_url=login_url,
) )

View File

@@ -8,13 +8,12 @@ log = logging.getLogger(__name__)
async def telegram_start(self: 'Usecase', telegram_id: int, chat_id: int) -> None: 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: if not user:
username_display = f'@{user.username}' if user.username else 'пользователь' message = 'Привет! 👋\n\nДля работы с ботом необходимо авторизоваться через веб-интерфейс.'
message = f'Привет, {username_display}! 👋\n\nДоступные команды:\n/new_creative - Создать новый креатив' await self.telegram_bot.send_message(message, chat_id)
else: return
message = 'Привет! 👋\n\nДля работы с ботом необходимо авторизоваться через веб-интерфейс.'
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)

View File

@@ -1,6 +1,7 @@
import datetime
import typing import typing
from tortoise import timezone
from src import domain, dto from src import domain, dto
if typing.TYPE_CHECKING: 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 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: if not login_token:
raise domain.LoginTokenNotFound() raise domain.LoginTokenNotFound()
if login_token.used_at: if login_token.used_at:
raise domain.LoginTokenAlreadyUsed() raise domain.LoginTokenAlreadyUsed()
if login_token.expires_at < datetime.datetime.now(datetime.UTC): if login_token.expires_at < timezone.now():
raise domain.LoginTokenExpired() raise domain.LoginTokenExpired()
user = await self.database.get_user(user_id=login_token.user_id) user = await self.database.get_user(user_id=login_token.user_id)
if not user: if not user:
raise domain.UserNotFound(login_token.user_id) 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( access_token = self.jwt_encoder.encode_access_token(
user_id=user.id, user_id=user.id,
telegram_id=user.telegram_id, telegram_id=user.telegram_id,

View File

@@ -11,31 +11,28 @@ log = logging.getLogger(__name__)
async def create_creative(self: 'Usecase', input: dto.CreateCreativeInput, user_id: uuid.UUID) -> dto.CreativeOutput: 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)
target_channel = await self.database.get_target_channel(user_id, channel_id=input.target_channel_id) if not target_channel:
if not target_channel: log.warning('User %s attempted to create creative for unavailable target %s', user_id, input.target_channel_id)
log.warning( raise domain.TargetChannelNotFound(input.target_channel_id)
'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( creative = domain.Creative(
name=input.name, name=input.name,
text=input.text, text=input.text,
status=domain.CreativeStatus.ACTIVE, status=domain.CreativeStatus.ACTIVE,
target_channel_id=target_channel.id, target_channel_id=target_channel.id,
user_id=user_id, user_id=user_id,
) )
created = await self.database.create_creative(creative) await self.database.create_creative(creative)
return dto.CreativeOutput( return dto.CreativeOutput(
id=created.id, id=creative.id,
name=created.name, name=creative.name,
text=created.text, text=creative.text,
target_channel_id=created.target_channel_id, target_channel_id=target_channel.id,
target_channel_title=created.target_channel.title, target_channel_title=target_channel.title,
created_at=created.created_at, created_at=creative.created_at,
status=created.status, status=creative.status,
placements_count=created.placements_count, placements_count=creative.placements_count,
) )

View File

@@ -10,14 +10,13 @@ log = logging.getLogger(__name__)
async def delete_creative(self: 'Usecase', input: dto.DeleteCreativeInput) -> None: 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)
creative = await self.database.get_creative(input.user_id, input.creative_id) if not creative:
if not creative: log.warning('User %s attempted to delete unavailable creative %s', input.user_id, input.creative_id)
log.warning('User %s attempted to delete unavailable creative %s', input.user_id, input.creative_id) raise domain.CreativeNotFound(input.creative_id)
raise domain.CreativeNotFound(input.creative_id)
if creative.placements_count > 0: if creative.placements_count > 0:
log.warning('Creative %s is used in placements and cannot be deleted', input.creative_id) log.warning('Creative %s is used in placements and cannot be deleted', input.creative_id)
raise domain.CreativeInUse(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)

View File

@@ -10,19 +10,18 @@ log = logging.getLogger(__name__)
async def get_creative(self: 'Usecase', input: dto.GetCreativeInput) -> dto.CreativeOutput: 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)
creative = await self.database.get_creative(input.user_id, input.creative_id) if not creative:
if not creative: log.warning('User %s attempted to access unavailable creative %s', input.user_id, input.creative_id)
log.warning('User %s attempted to access unavailable creative %s', input.user_id, input.creative_id) raise domain.CreativeNotFound(input.creative_id)
raise domain.CreativeNotFound(input.creative_id)
return dto.CreativeOutput( return dto.CreativeOutput(
id=creative.id, id=creative.id,
name=creative.name, name=creative.name,
text=creative.text, text=creative.text,
target_channel_id=creative.target_channel_id, target_channel_id=creative.target_channel_id,
target_channel_title=creative.target_channel.title, target_channel_title=creative.target_channel.title,
created_at=creative.created_at, created_at=creative.created_at,
status=creative.status, status=creative.status,
placements_count=creative.placements_count, placements_count=creative.placements_count,
) )

View File

@@ -7,10 +7,7 @@ if TYPE_CHECKING:
async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> dto.GetCreativesOutput: 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, input.target_channel_id, input.include_archived)
creatives = await self.database.get_user_creatives(
input.user_id, target_channel_id=input.target_channel_id, include_archived=input.include_archived
)
return dto.GetCreativesOutput( return dto.GetCreativesOutput(
creatives=[ creatives=[

View File

@@ -13,43 +13,40 @@ log = logging.getLogger(__name__)
async def handle_creative_callback( async def handle_creative_callback(
self: 'Usecase', telegram_id: int, chat_id: int, callback_data: str, message_id: int self: 'Usecase', telegram_id: int, chat_id: int, callback_data: str, message_id: int
) -> None: ) -> None:
async with self.database.transaction(): state = await self.database.get_telegram_state(telegram_id)
state = await self.database.get_telegram_state(telegram_id) if not state or state.state != domain.TelegramStateEnum.CREATIVE_WAITING_CHANNEL:
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)
log.debug('User %s has no active creative creation flow or wrong state', telegram_id) return
return
if not callback_data.startswith('cc_ch:'): if not callback_data.startswith('cc_ch:'):
log.warning('Invalid callback data format: %s', callback_data) log.warning('Invalid callback data format: %s', callback_data)
return return
channel_id_str = callback_data.split(':', 1)[1] channel_id_str = callback_data.split(':', 1)[1]
try: try:
channel_id = uuid.UUID(channel_id_str) channel_id = uuid.UUID(channel_id_str)
except ValueError: except ValueError:
log.error('Invalid channel UUID in callback data: %s', channel_id_str) log.error('Invalid channel UUID in callback data: %s', channel_id_str)
return return
user = await self.database.get_user(telegram_id=telegram_id) user = await self.database.get_user(telegram_id=telegram_id)
if not user: if not user:
await self.telegram_bot.send_message('❌ Ошибка: пользователь не найден.', chat_id=chat_id) await self.telegram_bot.send_message('❌ Ошибка: пользователь не найден.', chat_id)
await self.database.clear_telegram_state(telegram_id) await self.database.clear_telegram_state(telegram_id)
return return
channel = await self.database.get_target_channel(user.id, channel_id=channel_id) channel = await self.database.get_target_channel(user.id, channel_id=channel_id)
if not channel: if not channel:
await self.telegram_bot.send_message('❌ Канал не найден или не принадлежит вам.', chat_id=chat_id) await self.telegram_bot.send_message('❌ Канал не найден или не принадлежит вам.', chat_id)
await self.database.clear_telegram_state(telegram_id) await self.database.clear_telegram_state(telegram_id)
return return
await self.database.set_telegram_state( await self.database.set_telegram_state(
telegram_id=telegram_id, telegram_id,
state=domain.TelegramStateEnum.CREATIVE_WAITING_NAME, domain.TelegramStateEnum.CREATIVE_WAITING_NAME,
context={'target_channel_id': str(channel_id), 'channel_title': channel.title}, {'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( await self.telegram_bot.send_message(f'✅ Канал выбран: {channel.title}\n\n📝 Введите название креатива:', chat_id)
f'✅ Канал выбран: {channel.title}\n\n📝 Введите название креатива:', chat_id=chat_id
)

View File

@@ -13,86 +13,79 @@ log = logging.getLogger(__name__)
async def handle_creative_text_input( async def handle_creative_text_input(
self: 'Usecase', self: 'Usecase', telegram_id: int, chat_id: int, text: str, text_html: str | None = None
telegram_id: int,
chat_id: int,
text: str,
text_html: str | None = None,
) -> None: ) -> None:
async with self.database.transaction(): state = await self.database.get_telegram_state(telegram_id)
state = await self.database.get_telegram_state(telegram_id) if not state:
if not state: return
return
user = await self.database.get_user(telegram_id=telegram_id) user = await self.database.get_user(telegram_id=telegram_id)
if not user: if not user:
await self.telegram_bot.send_message('❌ Ошибка: пользователь не найден.', chat_id=chat_id) 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) await self.database.clear_telegram_state(telegram_id)
return return
if state.state == domain.TelegramStateEnum.CREATIVE_WAITING_NAME: try:
await self.database.set_telegram_state( target_channel_id = uuid.UUID(target_channel_id_str)
telegram_id=telegram_id, except ValueError:
state=domain.TelegramStateEnum.CREATIVE_WAITING_TEXT, log.error('Invalid target_channel_id in context: %s', target_channel_id_str)
context={**state.context, 'creative_name': text}, await self.telegram_bot.send_message('❌ Ошибка: неверный формат данных.', chat_id)
) await self.database.clear_telegram_state(telegram_id)
await self.telegram_bot.send_message( return
'✅ Название сохранено!\n\n'
'💬 Теперь отправьте или перешлите текст креатива.\n'
'Используйте {link} для обозначения места вставки ссылки на ваш канал.\n'
'Вы также можете указать текст ссылки в формате [текст]{link}.',
chat_id=chat_id,
)
elif state.state == domain.TelegramStateEnum.CREATIVE_WAITING_TEXT: channel = await self.database.get_target_channel(user.id, channel_id=target_channel_id)
target_channel_id_str = state.context.get('target_channel_id') if not channel:
creative_name = state.context.get('creative_name') 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: message_html = text_html or text
log.error('Missing context data for creative creation: %s', state.context) creative_text = prepare_creative_text_html(message_html)
await self.telegram_bot.send_message('❌ Ошибка: данные не найдены. Начните заново.', chat_id=chat_id)
await self.database.clear_telegram_state(telegram_id)
return
try: creative = domain.Creative(
target_channel_id = uuid.UUID(target_channel_id_str) name=creative_name,
except ValueError: text=creative_text,
log.error('Invalid target_channel_id in context: %s', target_channel_id_str) status=domain.CreativeStatus.ACTIVE,
await self.telegram_bot.send_message('❌ Ошибка: неверный формат данных.', chat_id=chat_id) user_id=user.id,
await self.database.clear_telegram_state(telegram_id) target_channel_id=target_channel_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)
async with self.database.transaction():
await self.database.create_creative(creative)
await self.database.clear_telegram_state(telegram_id) await self.database.clear_telegram_state(telegram_id)
text_preview_source = text text_preview_source = text
text_preview = text_preview_source[:100] + '...' if len(text_preview_source) > 100 else text_preview_source text_preview = text_preview_source[:100] + '...' if len(text_preview_source) > 100 else text_preview_source
await self.telegram_bot.send_message( await self.telegram_bot.send_message(
f'✅ Креатив успешно создан!\n\n' f'✅ Креатив успешно создан!\n\n'
f'📝 Название: {created.name}\n' f'📝 Название: {creative.name}\n'
f'📢 Канал: {channel.title}\n\n' f'📢 Канал: {channel.title}\n\n'
f'💬 Текст:\n{text_preview}', f'💬 Текст:\n{text_preview}',
chat_id=chat_id, chat_id,
) )
else: else:
log.warning('Unknown state: %s', state.state) log.warning('Unknown state: %s', state.state)
await self.database.clear_telegram_state(telegram_id) await self.database.clear_telegram_state(telegram_id)

View File

@@ -12,32 +12,22 @@ log = logging.getLogger(__name__)
async def start_creative_creation(self: 'Usecase', telegram_id: int, chat_id: int) -> None: 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)
user = await self.database.get_user(telegram_id=telegram_id) if not user:
if not user: await self.telegram_bot.send_message('❌ Вы не авторизованы. Используйте /start для входа.', chat_id)
await self.telegram_bot.send_message( return
'❌ Вы не авторизованы. Используйте /start для входа.', chat_id=chat_id
)
return
channels = await self.database.get_user_target_channels(user.id) channels = await self.database.get_user_target_channels(user.id)
if not channels:
if not channels: await self.telegram_bot.send_message(
await self.telegram_bot.send_message( 'У вас нет подключенных каналов. Добавьте бота в свой канал для начала работы.', chat_id
'У вас нет подключенных каналов. Добавьте бота в свой канал для начала работы.', 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,
) )
return
await self.database.set_telegram_state( buttons = [[InlineKeyboardButton(text=channel.title, callback_data=f'cc_ch:{channel.id}')] for channel in channels]
telegram_id=telegram_id, state=domain.TelegramStateEnum.CREATIVE_WAITING_CHANNEL, context={}
) 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={})

View File

@@ -13,28 +13,27 @@ log = logging.getLogger(__name__)
async def update_creative( async def update_creative(
self: 'Usecase', creative_id: uuid.UUID, input: dto.UpdateCreativeInput, user_id: uuid.UUID self: 'Usecase', creative_id: uuid.UUID, input: dto.UpdateCreativeInput, user_id: uuid.UUID
) -> dto.CreativeOutput: ) -> dto.CreativeOutput:
async with self.database.transaction(): creative = await self.database.get_creative(user_id, creative_id)
creative = await self.database.get_creative(user_id, creative_id) if not creative:
if not creative: log.warning('User %s attempted to update unavailable creative %s', user_id, creative_id)
log.warning('User %s attempted to update unavailable creative %s', user_id, creative_id) raise domain.CreativeNotFound(creative_id)
raise domain.CreativeNotFound(creative_id)
if input.name is not None: if input.name:
creative.name = input.name creative.name = input.name
if input.text is not None: if input.text:
creative.text = input.text creative.text = input.text
if input.status is not None: if input.status:
creative.status = input.status creative.status = input.status
updated = await self.database.update_creative(creative) await self.database.update_creative(creative)
return dto.CreativeOutput( return dto.CreativeOutput(
id=updated.id, id=creative.id,
name=updated.name, name=creative.name,
text=updated.text, text=creative.text,
target_channel_id=updated.target_channel_id, target_channel_id=creative.target_channel_id,
target_channel_title=updated.target_channel.title, target_channel_title=creative.target_channel.title,
created_at=updated.created_at, created_at=creative.created_at,
status=updated.status, status=creative.status,
placements_count=updated.placements_count, placements_count=creative.placements_count,
) )

View File

@@ -13,13 +13,14 @@ log = logging.getLogger(__name__)
async def create_external_channel( async def create_external_channel(
self: 'Usecase', input: dto.CreateExternalChannelInput, user_id: uuid.UUID self: 'Usecase', input: dto.CreateExternalChannelInput, user_id: uuid.UUID
) -> dto.ExternalChannelOutput: ) -> dto.ExternalChannelOutput:
async with self.database.transaction(): for target_id in input.target_channel_ids:
for target_id in input.target_channel_ids: target_channel = await self.database.get_target_channel(user_id, channel_id=target_id)
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)
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( channel = domain.ExternalChannel(
telegram_id=input.telegram_id, telegram_id=input.telegram_id,
title=input.title, title=input.title,
@@ -28,20 +29,20 @@ async def create_external_channel(
subscribers_count=input.subscribers_count, subscribers_count=input.subscribers_count,
user_id=user_id, 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: 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( log.info(
'External channel %s created and linked to %s target channels', input.telegram_id, len(input.target_channel_ids) 'External channel %s created and linked to %s target channels', input.telegram_id, len(input.target_channel_ids)
) )
return dto.ExternalChannelOutput( return dto.ExternalChannelOutput(
id=created_channel.id, id=channel.id,
telegram_id=created_channel.telegram_id, telegram_id=channel.telegram_id,
title=created_channel.title, title=channel.title,
username=created_channel.username, username=channel.username,
description=created_channel.description, description=channel.description,
subscribers_count=created_channel.subscribers_count, subscribers_count=channel.subscribers_count,
) )

View File

@@ -10,11 +10,10 @@ log = logging.getLogger(__name__)
async def delete_external_channel(self: 'Usecase', input: dto.DeleteExternalChannelInput) -> None: 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)
channel = await self.database.get_external_channel(input.user_id, channel_id=input.channel_id) if not channel:
if not channel: log.warning('User %s attempted to delete unavailable external channel %s', input.user_id, input.channel_id)
log.warning('User %s attempted to delete unavailable external channel %s', input.user_id, input.channel_id) raise domain.ExternalChannelNotFound(input.channel_id)
raise domain.ExternalChannelNotFound(input.channel_id)
await self.database.delete_external_channel(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) log.info('User %s deleted external channel %s', input.user_id, input.channel_id)

View File

@@ -10,15 +10,12 @@ log = logging.getLogger(__name__)
async def get_external_channels(self: 'Usecase', input: dto.GetExternalChannelsInput) -> dto.GetExternalChannelsOutput: 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)
target_channel = await self.database.get_target_channel(input.user_id, channel_id=input.target_channel_id) if not target_channel:
if not target_channel: log.warning('Target channel %s not found or not accessible for user %s', input.target_channel_id, input.user_id)
log.warning( raise domain.TargetChannelNotFound(input.target_channel_id)
'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( return dto.GetExternalChannelsOutput(
external_channels=[ external_channels=[

View File

@@ -13,30 +13,29 @@ log = logging.getLogger(__name__)
async def update_external_channel( async def update_external_channel(
self: 'Usecase', channel_id: uuid.UUID, input: dto.UpdateExternalChannelInput, user_id: uuid.UUID self: 'Usecase', channel_id: uuid.UUID, input: dto.UpdateExternalChannelInput, user_id: uuid.UUID
) -> dto.ExternalChannelOutput: ) -> dto.ExternalChannelOutput:
async with self.database.transaction(): external_channel = await self.database.get_external_channel(user_id, channel_id=channel_id)
external_channel = await self.database.get_external_channel(user_id, channel_id=channel_id) if not external_channel:
if not external_channel: log.warning('External channel %s not found', channel_id)
log.warning('External channel %s not found', channel_id) raise domain.ExternalChannelNotFound(channel_id)
raise domain.ExternalChannelNotFound(channel_id)
if input.title is not None: if input.title is not None:
external_channel.title = input.title external_channel.title = input.title
if input.username is not None: if input.username is not None:
external_channel.username = input.username external_channel.username = input.username
if input.description is not None: if input.description is not None:
external_channel.description = input.description external_channel.description = input.description
if input.subscribers_count is not None: if input.subscribers_count is not None:
external_channel.subscribers_count = input.subscribers_count 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( return dto.ExternalChannelOutput(
id=updated.id, id=external_channel.id,
telegram_id=updated.telegram_id, telegram_id=external_channel.telegram_id,
title=updated.title, title=external_channel.title,
username=updated.username, username=external_channel.username,
description=updated.description, description=external_channel.description,
subscribers_count=updated.subscribers_count, subscribers_count=external_channel.subscribers_count,
) )

View File

@@ -13,32 +13,30 @@ log = logging.getLogger(__name__)
async def update_external_channel_links( async def update_external_channel_links(
self: 'Usecase', channel_id: uuid.UUID, input: dto.UpdateExternalChannelLinksInput, user_id: uuid.UUID self: 'Usecase', channel_id: uuid.UUID, input: dto.UpdateExternalChannelLinksInput, user_id: uuid.UUID
) -> dto.ExternalChannelOutput: ) -> 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(): 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: if input.add_target_channel_ids:
await self.database.add_external_channel_to_targets(channel_id, 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: for target_id in input.remove_target_channel_ids:
await self.database.remove_external_channel_from_target(channel_id, target_id) await self.database.remove_external_channel_from_target(channel_id, target_id)
log.info( log.info(
'External channel %s links updated: %s added, %s removed', 'External channel %s links updated: %s added, %s removed',
channel_id, channel_id,
len(input.add_target_channel_ids), len(input.add_target_channel_ids),
len(input.remove_target_channel_ids), len(input.remove_target_channel_ids),
) )
return dto.ExternalChannelOutput( return dto.ExternalChannelOutput(
id=external_channel.id, id=external_channel.id,

View File

@@ -11,74 +11,63 @@ log = logging.getLogger(__name__)
async def create_placement(self: 'Usecase', input: dto.CreatePlacementInput, user_id: uuid.UUID) -> dto.PlacementOutput: 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(): async with self.database.transaction():
# Проверяем существование целевого канала await self.database.create_placement(placement)
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)
# Создаём уникальную пригласительную ссылку через 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 creative.placements_count += 1
await self.database.update_creative(creative) await self.database.update_creative(creative)
return dto.PlacementOutput( return dto.PlacementOutput(
id=created.id, id=placement.id,
target_channel_id=created.target_channel_id, target_channel_id=placement.target_channel_id,
target_channel_title=target_channel.title, target_channel_title=target_channel.title,
external_channel_id=created.external_channel_id, external_channel_id=placement.external_channel_id,
external_channel_title=external_channel.title, external_channel_title=external_channel.title,
creative_id=created.creative_id, creative_id=placement.creative_id,
creative_name=creative.name, creative_name=creative.name,
placement_date=created.placement_date, placement_date=placement.placement_date,
cost=created.cost, cost=placement.cost,
comment=created.comment, comment=placement.comment,
ad_post_url=created.ad_post_url, ad_post_url=placement.ad_post_url,
invite_link_type=created.invite_link_type, invite_link_type=placement.invite_link_type,
invite_link=created.invite_link, invite_link=placement.invite_link,
status=created.status, status=placement.status,
subscriptions_count=created.subscriptions_count, subscriptions_count=placement.subscriptions_count,
views_count=created.views_count, views_count=placement.views_count,
views_availability=created.views_availability, views_availability=placement.views_availability,
last_views_fetch_at=created.last_views_fetch_at, last_views_fetch_at=placement.last_views_fetch_at,
created_at=created.created_at, created_at=placement.created_at,
) )

View File

@@ -10,17 +10,19 @@ log = logging.getLogger(__name__)
async def delete_placement(self: 'Usecase', input: dto.DeletePlacementInput) -> None: 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)
placement = await self.database.get_placement(input.user_id, input.placement_id) if not placement:
if not placement: log.warning('Placement %s not found for user %s', input.placement_id, input.user_id)
log.warning('Placement %s not found for user %s', input.placement_id, input.user_id) raise domain.PlacementNotFound(input.placement_id)
raise domain.PlacementNotFound(input.placement_id)
# Уменьшаем счётчик закупов у креатива, если закуп был активным if placement.status != domain.PlacementStatus.ACTIVE:
if placement.status == domain.PlacementStatus.ACTIVE: await self.database.delete_placement(input.placement_id)
creative = await self.database.get_creative(input.user_id, placement.creative_id) return
if creative and creative.placements_count > 0:
creative.placements_count -= 1 async with self.database.transaction():
await self.database.update_creative(creative) 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) await self.database.delete_placement(input.placement_id)

View File

@@ -10,30 +10,29 @@ log = logging.getLogger(__name__)
async def get_placement(self: 'Usecase', input: dto.GetPlacementInput) -> dto.PlacementOutput: 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)
placement = await self.database.get_placement(input.user_id, input.placement_id) if not placement:
if not placement: log.warning('Placement %s not found for user %s', input.placement_id, input.user_id)
log.warning('Placement %s not found for user %s', input.placement_id, input.user_id) raise domain.PlacementNotFound(input.placement_id)
raise domain.PlacementNotFound(input.placement_id)
return dto.PlacementOutput( return dto.PlacementOutput(
id=placement.id, id=placement.id,
target_channel_id=placement.target_channel_id, target_channel_id=placement.target_channel_id,
target_channel_title=placement.target_channel.title, target_channel_title=placement.target_channel.title,
external_channel_id=placement.external_channel_id, external_channel_id=placement.external_channel_id,
external_channel_title=placement.external_channel.title, external_channel_title=placement.external_channel.title,
creative_id=placement.creative_id, creative_id=placement.creative_id,
creative_name=placement.creative.name, creative_name=placement.creative.name,
placement_date=placement.placement_date, placement_date=placement.placement_date,
cost=placement.cost, cost=placement.cost,
comment=placement.comment, comment=placement.comment,
ad_post_url=placement.ad_post_url, ad_post_url=placement.ad_post_url,
invite_link_type=placement.invite_link_type, invite_link_type=placement.invite_link_type,
invite_link=placement.invite_link, invite_link=placement.invite_link,
status=placement.status, status=placement.status,
subscriptions_count=placement.subscriptions_count, subscriptions_count=placement.subscriptions_count,
views_count=placement.views_count, views_count=placement.views_count,
views_availability=placement.views_availability, views_availability=placement.views_availability,
last_views_fetch_at=placement.last_views_fetch_at, last_views_fetch_at=placement.last_views_fetch_at,
created_at=placement.created_at, created_at=placement.created_at,
) )

View File

@@ -7,14 +7,13 @@ if TYPE_CHECKING:
async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.GetPlacementsOutput: async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.GetPlacementsOutput:
async with self.database.transaction(): placements = await self.database.get_user_placements(
placements = await self.database.get_user_placements( input.user_id,
input.user_id, target_channel_id=input.target_channel_id,
target_channel_id=input.target_channel_id, external_channel_id=input.external_channel_id,
external_channel_id=input.external_channel_id, creative_id=input.creative_id,
creative_id=input.creative_id, include_archived=input.include_archived,
include_archived=input.include_archived, )
)
return dto.GetPlacementsOutput( return dto.GetPlacementsOutput(
placements=[ placements=[

View File

@@ -13,42 +13,40 @@ log = logging.getLogger(__name__)
async def update_placement( async def update_placement(
self: 'Usecase', placement_id: uuid.UUID, input: dto.UpdatePlacementInput, user_id: uuid.UUID self: 'Usecase', placement_id: uuid.UUID, input: dto.UpdatePlacementInput, user_id: uuid.UUID
) -> dto.PlacementOutput: ) -> dto.PlacementOutput:
async with self.database.transaction(): placement = await self.database.get_placement(user_id, placement_id)
placement = await self.database.get_placement(user_id, placement_id) if not placement:
if not placement: log.warning('Placement %s not found for user %s', placement_id, user_id)
log.warning('Placement %s not found for user %s', placement_id, user_id) raise domain.PlacementNotFound(placement_id)
raise domain.PlacementNotFound(placement_id)
# Обновляем только те поля, которые были переданы if input.placement_date is not None:
if input.placement_date is not None: placement.placement_date = input.placement_date
placement.placement_date = input.placement_date if input.cost is not None:
if input.cost is not None: placement.cost = input.cost
placement.cost = input.cost if input.comment is not None:
if input.comment is not None: placement.comment = input.comment
placement.comment = input.comment if input.status is not None:
if input.status is not None: placement.status = input.status
placement.status = input.status
updated = await self.database.update_placement(placement) await self.database.update_placement(placement)
return dto.PlacementOutput( return dto.PlacementOutput(
id=updated.id, id=placement.id,
target_channel_id=updated.target_channel_id, target_channel_id=placement.target_channel_id,
target_channel_title=updated.target_channel.title, target_channel_title=placement.target_channel.title,
external_channel_id=updated.external_channel_id, external_channel_id=placement.external_channel_id,
external_channel_title=updated.external_channel.title, external_channel_title=placement.external_channel.title,
creative_id=updated.creative_id, creative_id=placement.creative_id,
creative_name=updated.creative.name, creative_name=placement.creative.name,
placement_date=updated.placement_date, placement_date=placement.placement_date,
cost=updated.cost, cost=placement.cost,
comment=updated.comment, comment=placement.comment,
ad_post_url=updated.ad_post_url, ad_post_url=placement.ad_post_url,
invite_link_type=updated.invite_link_type, invite_link_type=placement.invite_link_type,
invite_link=updated.invite_link, invite_link=placement.invite_link,
status=updated.status, status=placement.status,
subscriptions_count=updated.subscriptions_count, subscriptions_count=placement.subscriptions_count,
views_count=updated.views_count, views_count=placement.views_count,
views_availability=updated.views_availability, views_availability=placement.views_availability,
last_views_fetch_at=updated.last_views_fetch_at, last_views_fetch_at=placement.last_views_fetch_at,
created_at=updated.created_at, created_at=placement.created_at,
) )

View File

@@ -17,44 +17,43 @@ async def handle_subscription(
first_name: str | None = None, first_name: str | None = None,
last_name: str | None = None, last_name: str | None = None,
) -> None: ) -> None:
async with self.database.transaction(): placement = await self.database.get_placement_by_invite_link(invite_link)
placement = await self.database.get_placement_by_invite_link(invite_link) if not placement:
if not placement: log.warning('Placement not found for invite_link: %s', invite_link)
log.warning('Placement not found for invite_link: %s', invite_link) return
return
subscriber = domain.Subscriber( subscriber = domain.Subscriber(
telegram_id=user_telegram_id, telegram_id=user_telegram_id,
username=username, username=username,
first_name=first_name, first_name=first_name,
last_name=last_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( # Проверяем, была ли раньше подписка через ЭТОТ placement (для реактивации)
subscriber.id, placement.target_channel.telegram_id existing_sub = await self.database.get_subscription_by_subscriber_and_placement(subscriber.id, placement.id)
)
if active_subscription: if existing_sub and existing_sub.status == domain.SubscriptionStatus.UNSUBSCRIBED:
# Пользователь уже подписан на канал через другой placement async with self.database.transaction():
# Это не должно случиться (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
existing_sub.status = domain.SubscriptionStatus.ACTIVE existing_sub.status = domain.SubscriptionStatus.ACTIVE
existing_sub.unsubscribed_at = None existing_sub.unsubscribed_at = None
await self.database.update_subscription(existing_sub) await self.database.update_subscription(existing_sub)
@@ -62,29 +61,30 @@ async def handle_subscription(
placement.subscriptions_count += 1 placement.subscriptions_count += 1
await self.database.update_placement(placement) await self.database.update_placement(placement)
log.info( log.info(
'Subscription reactivated: subscriber %s (telegram_id: %s) resubscribed via same placement %s', 'Subscription reactivated: subscriber %s (telegram_id: %s) resubscribed via same placement %s',
subscriber.id, subscriber.id,
user_telegram_id, user_telegram_id,
placement.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,
) )
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) await self.database.create_subscription(subscription)
placement.subscriptions_count += 1 placement.subscriptions_count += 1
await self.database.update_placement(placement) await self.database.update_placement(placement)
log.info( log.info(
'Subscription created: subscriber %s (telegram_id: %s) subscribed via placement %s (invite_link: %s)', 'Subscription created: subscriber %s (telegram_id: %s) subscribed via placement %s (invite_link: %s)',
subscriber.id, subscriber.id,
user_telegram_id, user_telegram_id,
placement.id, placement.id,
invite_link, invite_link,
) )

View File

@@ -1,7 +1,8 @@
import datetime
import logging import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from tortoise import timezone
from src import domain from src import domain
if TYPE_CHECKING: 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 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)
subscriber = await self.database.get_subscriber(user_telegram_id) if not subscriber:
if not subscriber: log.warning('Subscriber not found for telegram_id: %s', user_telegram_id)
log.warning('Subscriber not found for telegram_id: %s', user_telegram_id) return
return
subscriptions = await self.database.get_active_subscriptions_by_subscriber_and_channel( subscriptions = await self.database.get_active_subscriptions_by_subscriber_and_channel(
subscriber.id, channel_telegram_id 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: for subscription in subscriptions:
log.info( async with self.database.transaction():
'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:
subscription.status = domain.SubscriptionStatus.UNSUBSCRIBED subscription.status = domain.SubscriptionStatus.UNSUBSCRIBED
subscription.unsubscribed_at = datetime.datetime.now(datetime.UTC) subscription.unsubscribed_at = timezone.now()
await self.database.update_subscription(subscription) await self.database.update_subscription(subscription)
# Декрементируем счётчик подписок у закупа
placement = subscription.placement placement = subscription.placement
if placement.subscriptions_count > 0: if placement.subscriptions_count > 0:
placement.subscriptions_count -= 1 placement.subscriptions_count -= 1
await self.database.update_placement(placement) await self.database.update_placement(placement)
log.info( log.info(
'Subscription marked as unsubscribed: subscriber %s (telegram_id: %s) unsubscribed from placement %s', 'Subscription marked as unsubscribed: subscriber %s (telegram_id: %s) unsubscribed from placement %s',
subscriber.id, subscriber.id,
user_telegram_id, user_telegram_id,
placement.id, placement.id,
) )

View File

@@ -17,7 +17,7 @@ async def connect_target_chan(self: 'Usecase', input: dto.ConnectTargetChanInput
await self.telegram_bot.send_message( await self.telegram_bot.send_message(
f'⚠️ Бот был добавлен в канал "{input.title}", но не является админом.\n\n' f'⚠️ Бот был добавлен в канал "{input.title}", но не является админом.\n\n'
'Пожалуйста, сделайте бота администратором канала.', 'Пожалуйста, сделайте бота администратором канала.',
chat_id=input.user_telegram_id, input.user_telegram_id,
) )
raise domain.ChannelNoAdminRights() raise domain.ChannelNoAdminRights()
@@ -37,42 +37,38 @@ async def connect_target_chan(self: 'Usecase', input: dto.ConnectTargetChanInput
f'⚠️ Бот был добавлен в канал "{input.title}", но не имеет необходимых прав.\n\n' f'⚠️ Бот был добавлен в канал "{input.title}", но не имеет необходимых прав.\n\n'
f'Отсутствующие права:\n{permissions_text}\n\n' f'Отсутствующие права:\n{permissions_text}\n\n'
'Пожалуйста, предоставьте эти права боту в настройках канала.', 'Пожалуйста, предоставьте эти права боту в настройках канала.',
chat_id=input.user_telegram_id, input.user_telegram_id,
) )
raise domain.ChannelNoAdminRights() raise domain.ChannelNoAdminRights()
async with self.database.transaction(): user = await self.database.get_user(telegram_id=input.user_telegram_id)
user = await self.database.get_user(telegram_id=input.user_telegram_id) if not user:
if not user: log.warning(f'User {input.user_telegram_id} not found when trying to connect channel {input.telegram_id}')
log.warning(f'User {input.user_telegram_id} not found when trying to connect channel {input.telegram_id}') await self.telegram_bot.send_message(
await self.telegram_bot.send_message( f'⚠️ Канал "{input.title}" не может быть подключен.\n\n'
f'⚠️ Канал "{input.title}" не может быть подключен.\n\n' 'Вы должны сначала авторизоваться в веб-панели перед подключением каналов.',
'Вы должны сначала авторизоваться в веб-панели перед подключением каналов.', input.user_telegram_id,
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,
) )
raise domain.UserNotFound()
upserted_channel = await self.database.upsert_target_channel(channel) channel = domain.TargetChannel(
log.info(f'Channel {input.telegram_id} connected/updated successfully by user {input.user_telegram_id}') telegram_id=input.telegram_id,
title=input.title,
await self.telegram_bot.send_message( username=input.username,
f'✅ Канал "{input.title}" успешно подключен!', user_id=user.id,
chat_id=input.user_telegram_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( return dto.ConnectTargetChanOutput(
id=upserted_channel.id, id=channel.id,
telegram_id=upserted_channel.telegram_id, telegram_id=channel.telegram_id,
title=upserted_channel.title, title=channel.title,
username=upserted_channel.username, username=channel.username,
status=upserted_channel.status, status=channel.status,
is_active=upserted_channel.status == domain.TargetChannelStatus.ACTIVE, is_active=channel.status == domain.TargetChannelStatus.ACTIVE,
) )

View File

@@ -10,12 +10,11 @@ log = logging.getLogger(__name__)
async def disconnect_target_chan(self: 'Usecase', input: dto.DisconnectTargetChanInput) -> None: 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)
channel = await self.database.get_target_channel(channel_id=input.channel_id, user_id=input.user_id) if not channel:
if not channel: raise domain.TargetChannelNotFound(input.channel_id)
raise domain.TargetChannelNotFound(input.channel_id)
channel.status = domain.TargetChannelStatus.INACTIVE channel.status = domain.TargetChannelStatus.INACTIVE
await self.database.update_target_channel(channel) await self.database.update_target_channel(channel)
log.info(f'Target channel {input.channel_id} deactivated') log.info(f'Target channel {input.channel_id} deactivated')

View File

@@ -10,18 +10,17 @@ log = logging.getLogger(__name__)
async def disconnect_target_chan_by_tg_id(self: 'Usecase', input: dto.DisconnectTargetChanByTgIdInput) -> None: 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)
user = await self.database.get_user(telegram_id=input.user_telegram_id) if not user:
if not user: log.warning(f'User with telegram_id {input.user_telegram_id} not found when disconnecting channel')
log.warning(f'User with telegram_id {input.user_telegram_id} not found when disconnecting channel') return
return
channel = await self.database.get_target_channel(user.id, telegram_id=input.telegram_id) channel = await self.database.get_target_channel(user.id, telegram_id=input.telegram_id)
if not channel: if not channel:
log.warning(f'Target channel {input.telegram_id} not found') log.warning(f'Target channel {input.telegram_id} not found')
raise domain.TargetChannelNotFound() raise domain.TargetChannelNotFound()
channel.status = domain.TargetChannelStatus.INACTIVE channel.status = domain.TargetChannelStatus.INACTIVE
await self.database.update_target_channel(channel) await self.database.update_target_channel(channel)
log.info(f'Target channel {input.telegram_id} deactivated') log.info(f'Target channel {input.telegram_id} deactivated')

View File

@@ -7,8 +7,7 @@ if TYPE_CHECKING:
async def get_user_target_chans(self: 'Usecase', input: dto.GetUserTargetChansInput) -> dto.GetUserTargetChansOutput: 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( return dto.GetUserTargetChansOutput(
target_channels=[ target_channels=[

View File

@@ -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') log.warning(f'User with telegram_id {input.user_telegram_id} not found when updating channel permissions')
return return
async with self.database.transaction(): channel = await self.database.get_target_channel(user.id, telegram_id=input.telegram_id)
channel = await self.database.get_target_channel(user.id, telegram_id=input.telegram_id) if not channel:
if not channel: log.warning(f'Target channel {input.telegram_id} not found when permissions changed')
log.warning(f'Target channel {input.telegram_id} not found when permissions changed') raise domain.TargetChannelNotFound()
raise domain.TargetChannelNotFound()
if not missing_permissions: if not missing_permissions:
if channel.status != domain.TargetChannelStatus.ACTIVE: if channel.status != domain.TargetChannelStatus.ACTIVE:
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
await self.database.update_target_channel(channel) await self.database.update_target_channel(channel)
missed_permissions = '\n'.join(f'{p}' for p in missing_permissions)
await self.telegram_bot.send_message( await self.telegram_bot.send_message(
f'⚠️ Канал "{input.chat_title}" был деактивирован.\ f' Канал "{input.chat_title}" был активирован.\n\nВсе необходимые права боту предоставлены!',
\n\nБоту убрали необходимые права:\n{missed_permissions}', input.user_telegram_id,
chat_id=input.user_telegram_id,
)
log.warning(
f'Target channel {input.telegram_id} deactivated due to missing permissions: {missing_permissions}'
) )
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}')

View File

@@ -10,18 +10,16 @@ log = logging.getLogger(__name__)
async def get_views_history(self: 'Usecase', input: dto.GetViewsHistoryInput) -> dto.GetViewsHistoryOutput: async def get_views_history(self: 'Usecase', input: dto.GetViewsHistoryInput) -> dto.GetViewsHistoryOutput:
"""Получить историю просмотров для закупа.""" placement = await self.database.get_placement(input.user_id, input.placement_id)
async with self.database.transaction(): if not placement:
placement = await self.database.get_placement(input.user_id, input.placement_id) log.warning('Placement %s not found for user %s', input.placement_id, input.user_id)
if not placement: raise domain.PlacementNotFound(input.placement_id)
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( histories = await self.database.get_views_history(
input.placement_id, input.placement_id,
from_date=input.from_date, from_date=input.from_date,
to_date=input.to_date, to_date=input.to_date,
) )
return dto.GetViewsHistoryOutput( return dto.GetViewsHistoryOutput(
histories=[ histories=[

View File

@@ -1,7 +1,8 @@
import datetime
import logging import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from tortoise import timezone
from src import domain, dto from src import domain, dto
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -11,14 +12,14 @@ log = logging.getLogger(__name__)
async def update_views_manually(self: 'Usecase', input: dto.UpdateViewsManuallyInput) -> dto.PlacementOutput: 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(): 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_count = input.views_count
placement.views_availability = domain.PostViewsAvailability.MANUAL placement.views_availability = domain.PostViewsAvailability.MANUAL
placement.last_views_fetch_at = fetched_at 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) 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( return dto.PlacementOutput(
id=placement.id, id=placement.id,
target_channel_id=placement.target_channel_id, target_channel_id=placement.target_channel_id,
target_channel_title=placement.target_channel.title, target_channel_title=placement.target_channel.title,
external_channel_id=placement.external_channel_id, external_channel_id=placement.external_channel_id,
external_channel_title=placement.external_channel.title, external_channel_title=placement.external_channel.title,
creative_id=placement.creative_id, creative_id=placement.creative_id,
creative_name=placement.creative.name, creative_name=placement.creative.name,
placement_date=placement.placement_date, placement_date=placement.placement_date,
cost=placement.cost, cost=placement.cost,
comment=placement.comment, comment=placement.comment,
ad_post_url=placement.ad_post_url, ad_post_url=placement.ad_post_url,
invite_link_type=placement.invite_link_type, invite_link_type=placement.invite_link_type,
invite_link=placement.invite_link, invite_link=placement.invite_link,
status=placement.status, status=placement.status,
subscriptions_count=placement.subscriptions_count, subscriptions_count=placement.subscriptions_count,
views_count=placement.views_count, views_count=placement.views_count,
views_availability=placement.views_availability, views_availability=placement.views_availability,
last_views_fetch_at=placement.last_views_fetch_at, last_views_fetch_at=placement.last_views_fetch_at,
created_at=placement.created_at, created_at=placement.created_at,
) )

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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) == 'Подписывайтесь: <a class="tg-link"></a>!'
def test_label_placeholder_is_wrapped() -> None:
source = 'Жми [сюда]{link} скорее'
assert prepare_creative_text_html(source) == 'Жми <a class="tg-link">сюда</a> скорее'
def test_placeholder_replacement_is_case_insensitive() -> None:
source = 'Первый {LINK}, второй {Link}.'
expected = 'Первый <a class="tg-link"></a>, второй <a class="tg-link"></a>.'
assert prepare_creative_text_html(source) == expected
def test_plain_html_left_untouched() -> None:
source = '<b>Жми</b> и <i>делись</i>'
assert prepare_creative_text_html(source) == source

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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