feat: subs and views

This commit is contained in:
Artem Tsyrulnikov
2025-11-10 17:08:56 +03:00
parent 2549242b06
commit 0bed5c266d
34 changed files with 1706 additions and 119 deletions

View File

@@ -22,6 +22,7 @@ from .purchase.delete_purchase import delete_purchase
from .purchase.get_purchase import get_purchase
from .purchase.get_purchases import get_purchases
from .purchase.update_purchase import update_purchase
from .subscription.handle_subscription import handle_subscription
from .target_channel.connect_target_chan import connect_target_chan
from .target_channel.disconnect_target_chan import disconnect_target_chan
from .target_channel.disconnect_target_chan_by_tg_id import disconnect_target_chan_by_tg_id
@@ -29,6 +30,9 @@ from .target_channel.get_user_target_chans import get_user_target_chans
from .target_channel.update_target_chan_permissions import update_target_chan_permissions
from .telegram_login import telegram_login
from .validate_login_token import validate_login_token
from .views.fetch_views import fetch_views
from .views.get_views_history import get_views_history
from .views.update_views_manually import update_views_manually
class Database(typing.Protocol):
@@ -110,6 +114,24 @@ class Database(typing.Protocol):
async def check_creative_in_use(self, creative_id: UUID) -> bool: ...
async def get_purchase_by_invite_link(self, invite_link: str) -> domain.Purchase | None: ...
async def create_subscription(self, subscription: domain.Subscription) -> domain.Subscription: ...
async def get_subscription_by_user_and_purchase(
self, user_telegram_id: int, purchase_id: UUID
) -> domain.Subscription | None: ...
async def create_views_snapshot(self, snapshot: domain.ViewsSnapshot) -> domain.ViewsSnapshot: ...
async def get_views_history(
self,
purchase_id: UUID,
*,
from_date: datetime.datetime | None = None,
to_date: datetime.datetime | None = None,
) -> list[domain.ViewsSnapshot]: ...
class TelegramWriter(typing.Protocol):
async def send_message(self, text: str, chat_id: int) -> None: ...
@@ -120,6 +142,8 @@ class TelegramWriter(typing.Protocol):
async def create_chat_invite_link(self, chat_id: int, requires_approval: bool = False) -> str: ...
async def get_post_views(self, post_url: str) -> int | None: ... # None если не удалось получить
class JWTEncoder(typing.Protocol):
def encode_access_token(self, user_id: UUID, telegram_id: int, username: str | None = None) -> str: ...
@@ -155,3 +179,7 @@ class Usecase:
update_purchase = update_purchase
archive_purchase = archive_purchase
delete_purchase = delete_purchase
handle_subscription = handle_subscription
fetch_views = fetch_views
get_views_history = get_views_history
update_views_manually = update_views_manually

View File

@@ -20,13 +20,13 @@ async def archive_creative(self: 'Usecase', creative_id: uuid.UUID, user_id: uui
creative.status = domain.CreativeStatus.ARCHIVED
updated = await self.database.update_creative(creative)
return dto.CreativeOutput(
id=updated.id,
name=updated.name,
text=updated.text,
target_channel_id=updated.target_channel_id,
target_channel_title=updated.target_channel.title,
created_at=updated.created_at,
status=updated.status,
purchases_count=updated.purchases_count,
)
return dto.CreativeOutput(
id=updated.id,
name=updated.name,
text=updated.text,
target_channel_id=updated.target_channel_id,
target_channel_title=updated.target_channel.title,
created_at=updated.created_at,
status=updated.status,
purchases_count=updated.purchases_count,
)

View File

@@ -22,19 +22,20 @@ async def create_creative(self: 'Usecase', input: dto.CreateCreativeInput, user_
creative = domain.Creative(
name=input.name,
text=input.text,
status=domain.CreativeStatus.ACTIVE,
target_channel_id=target_channel.id,
user_id=user_id,
)
created = await self.database.create_creative(creative)
return dto.CreativeOutput(
id=created.id,
name=created.name,
text=created.text,
target_channel_id=created.target_channel_id,
target_channel_title=created.target_channel.title,
created_at=created.created_at,
status=created.status,
purchases_count=created.purchases_count,
)
return dto.CreativeOutput(
id=created.id,
name=created.name,
text=created.text,
target_channel_id=created.target_channel_id,
target_channel_title=created.target_channel.title,
created_at=created.created_at,
status=created.status,
purchases_count=created.purchases_count,
)

View File

@@ -16,13 +16,13 @@ async def get_creative(self: 'Usecase', input: dto.GetCreativeInput) -> dto.Crea
log.warning('User %s attempted to access unavailable creative %s', input.user_id, input.creative_id)
raise domain.CreativeNotFound(input.creative_id)
return dto.CreativeOutput(
id=creative.id,
name=creative.name,
text=creative.text,
target_channel_id=creative.target_channel_id,
target_channel_title=creative.target_channel.title,
created_at=creative.created_at,
status=creative.status,
purchases_count=creative.purchases_count,
)
return dto.CreativeOutput(
id=creative.id,
name=creative.name,
text=creative.text,
target_channel_id=creative.target_channel_id,
target_channel_title=creative.target_channel.title,
created_at=creative.created_at,
status=creative.status,
purchases_count=creative.purchases_count,
)

View File

@@ -26,13 +26,13 @@ async def update_creative(
updated = await self.database.update_creative(creative)
return dto.CreativeOutput(
id=updated.id,
name=updated.name,
text=updated.text,
target_channel_id=updated.target_channel_id,
target_channel_title=updated.target_channel.title,
created_at=updated.created_at,
status=updated.status,
purchases_count=updated.purchases_count,
)
return dto.CreativeOutput(
id=updated.id,
name=updated.name,
text=updated.text,
target_channel_id=updated.target_channel_id,
target_channel_title=updated.target_channel.title,
created_at=updated.created_at,
status=updated.status,
purchases_count=updated.purchases_count,
)

View File

@@ -20,22 +20,24 @@ async def archive_purchase(self: 'Usecase', purchase_id: uuid.UUID, user_id: uui
purchase.status = domain.PurchaseStatus.ARCHIVED
updated = await self.database.update_purchase(purchase)
return dto.PurchaseOutput(
id=updated.id,
target_channel_id=updated.target_channel_id,
target_channel_title=updated.target_channel.title,
external_channel_id=updated.external_channel_id,
external_channel_title=updated.external_channel.title,
creative_id=updated.creative_id,
creative_name=updated.creative.name,
placement_date=updated.placement_date,
cost=updated.cost,
comment=updated.comment,
ad_post_url=updated.ad_post_url,
invite_link_type=updated.invite_link_type,
invite_link=updated.invite_link,
status=updated.status,
subscriptions_count=updated.subscriptions_count,
views_count=updated.views_count,
created_at=updated.created_at,
)
return dto.PurchaseOutput(
id=updated.id,
target_channel_id=updated.target_channel_id,
target_channel_title=updated.target_channel.title,
external_channel_id=updated.external_channel_id,
external_channel_title=updated.external_channel.title,
creative_id=updated.creative_id,
creative_name=updated.creative.name,
placement_date=updated.placement_date,
cost=updated.cost,
comment=updated.comment,
ad_post_url=updated.ad_post_url,
invite_link_type=updated.invite_link_type,
invite_link=updated.invite_link,
status=updated.status,
subscriptions_count=updated.subscriptions_count,
views_count=updated.views_count,
views_availability=updated.views_availability,
last_views_fetch_at=updated.last_views_fetch_at,
created_at=updated.created_at,
)

View File

@@ -62,22 +62,25 @@ async def create_purchase(self: 'Usecase', input: dto.CreatePurchaseInput, user_
creative.purchases_count += 1
await self.database.update_creative(creative)
return dto.PurchaseOutput(
id=created.id,
target_channel_id=created.target_channel_id,
target_channel_title=created.target_channel.title,
external_channel_id=created.external_channel_id,
external_channel_title=created.external_channel.title,
creative_id=created.creative_id,
creative_name=created.creative.name,
placement_date=created.placement_date,
cost=created.cost,
comment=created.comment,
ad_post_url=created.ad_post_url,
invite_link_type=created.invite_link_type,
invite_link=created.invite_link,
status=created.status,
subscriptions_count=created.subscriptions_count,
views_count=created.views_count,
created_at=created.created_at,
)
# Формируем ответ внутри транзакции, пока объекты в сессии
return dto.PurchaseOutput(
id=created.id,
target_channel_id=created.target_channel_id,
target_channel_title=target_channel.title,
external_channel_id=created.external_channel_id,
external_channel_title=external_channel.title,
creative_id=created.creative_id,
creative_name=creative.name,
placement_date=created.placement_date,
cost=created.cost,
comment=created.comment,
ad_post_url=created.ad_post_url,
invite_link_type=created.invite_link_type,
invite_link=created.invite_link,
status=created.status,
subscriptions_count=created.subscriptions_count,
views_count=created.views_count,
views_availability=created.views_availability,
last_views_fetch_at=created.last_views_fetch_at,
created_at=created.created_at,
)

View File

@@ -16,22 +16,24 @@ async def get_purchase(self: 'Usecase', input: dto.GetPurchaseInput) -> dto.Purc
log.warning('Purchase %s not found for user %s', input.purchase_id, input.user_id)
raise domain.PurchaseNotFound(input.purchase_id)
return dto.PurchaseOutput(
id=purchase.id,
target_channel_id=purchase.target_channel_id,
target_channel_title=purchase.target_channel.title,
external_channel_id=purchase.external_channel_id,
external_channel_title=purchase.external_channel.title,
creative_id=purchase.creative_id,
creative_name=purchase.creative.name,
placement_date=purchase.placement_date,
cost=purchase.cost,
comment=purchase.comment,
ad_post_url=purchase.ad_post_url,
invite_link_type=purchase.invite_link_type,
invite_link=purchase.invite_link,
status=purchase.status,
subscriptions_count=purchase.subscriptions_count,
views_count=purchase.views_count,
created_at=purchase.created_at,
)
return dto.PurchaseOutput(
id=purchase.id,
target_channel_id=purchase.target_channel_id,
target_channel_title=purchase.target_channel.title,
external_channel_id=purchase.external_channel_id,
external_channel_title=purchase.external_channel.title,
creative_id=purchase.creative_id,
creative_name=purchase.creative.name,
placement_date=purchase.placement_date,
cost=purchase.cost,
comment=purchase.comment,
ad_post_url=purchase.ad_post_url,
invite_link_type=purchase.invite_link_type,
invite_link=purchase.invite_link,
status=purchase.status,
subscriptions_count=purchase.subscriptions_count,
views_count=purchase.views_count,
views_availability=purchase.views_availability,
last_views_fetch_at=purchase.last_views_fetch_at,
created_at=purchase.created_at,
)

View File

@@ -35,6 +35,8 @@ async def get_purchases(self: 'Usecase', input: dto.GetPurchasesInput) -> dto.Ge
status=purchase.status,
subscriptions_count=purchase.subscriptions_count,
views_count=purchase.views_count,
views_availability=purchase.views_availability,
last_views_fetch_at=purchase.last_views_fetch_at,
created_at=purchase.created_at,
)
for purchase in purchases

View File

@@ -31,22 +31,24 @@ async def update_purchase(
updated = await self.database.update_purchase(purchase)
return dto.PurchaseOutput(
id=updated.id,
target_channel_id=updated.target_channel_id,
target_channel_title=updated.target_channel.title,
external_channel_id=updated.external_channel_id,
external_channel_title=updated.external_channel.title,
creative_id=updated.creative_id,
creative_name=updated.creative.name,
placement_date=updated.placement_date,
cost=updated.cost,
comment=updated.comment,
ad_post_url=updated.ad_post_url,
invite_link_type=updated.invite_link_type,
invite_link=updated.invite_link,
status=updated.status,
subscriptions_count=updated.subscriptions_count,
views_count=updated.views_count,
created_at=updated.created_at,
)
return dto.PurchaseOutput(
id=updated.id,
target_channel_id=updated.target_channel_id,
target_channel_title=updated.target_channel.title,
external_channel_id=updated.external_channel_id,
external_channel_title=updated.external_channel.title,
creative_id=updated.creative_id,
creative_name=updated.creative.name,
placement_date=updated.placement_date,
cost=updated.cost,
comment=updated.comment,
ad_post_url=updated.ad_post_url,
invite_link_type=updated.invite_link_type,
invite_link=updated.invite_link,
status=updated.status,
subscriptions_count=updated.subscriptions_count,
views_count=updated.views_count,
views_availability=updated.views_availability,
last_views_fetch_at=updated.last_views_fetch_at,
created_at=updated.created_at,
)

View File

@@ -0,0 +1,59 @@
import logging
from typing import TYPE_CHECKING
from src import domain
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def handle_subscription(
self: 'Usecase',
user_telegram_id: int,
username: str | None,
invite_link: str,
) -> None:
"""
Обрабатывает подписку пользователя в целевой канал.
Находит закуп по invite_link и создаёт запись о подписке,
инкрементируя счётчик подписок у закупа.
"""
async with self.database.transaction():
# Находим закуп по пригласительной ссылке
purchase = await self.database.get_purchase_by_invite_link(invite_link)
if not purchase:
log.warning('Purchase not found for invite_link: %s', invite_link)
return
# Проверяем, не создана ли уже подписка для этого пользователя
existing_subscription = await self.database.get_subscription_by_user_and_purchase(user_telegram_id, purchase.id)
if existing_subscription:
log.info(
'Subscription already exists for user %s and purchase %s',
user_telegram_id,
purchase.id,
)
return
# Создаём запись о подписке
subscription = domain.Subscription(
purchase_id=purchase.id,
user_telegram_id=user_telegram_id,
username=username,
invite_link=invite_link,
)
await self.database.create_subscription(subscription)
# Увеличиваем счётчик подписок у закупа
purchase.subscriptions_count += 1
await self.database.update_purchase(purchase)
log.info(
'Subscription created: user %s subscribed via purchase %s (invite_link: %s)',
user_telegram_id,
purchase.id,
invite_link,
)

View File

@@ -0,0 +1,77 @@
import datetime
import logging
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def fetch_views(self: 'Usecase', input: dto.FetchViewsInput) -> dto.FetchViewsOutput:
"""
Получить просмотры для закупа через Telegram API.
Создаёт снимок просмотров и обновляет метрики закупа.
"""
async with self.database.transaction():
purchase = await self.database.get_purchase(input.user_id, input.purchase_id)
if not purchase:
log.warning('Purchase %s not found for user %s', input.purchase_id, input.user_id)
raise domain.PurchaseNotFound(input.purchase_id)
if not purchase.ad_post_url:
log.warning('Purchase %s has no ad_post_url', input.purchase_id)
return dto.FetchViewsOutput(
purchase_id=purchase.id,
views_count=None,
views_availability=domain.PostViewsAvailability.UNAVAILABLE,
fetched_at=datetime.datetime.now(datetime.UTC),
error_message='No ad_post_url specified',
)
# Попытка получить просмотры через Telegram API
views_count = await self.telegram_writer.get_post_views(purchase.ad_post_url)
fetched_at = datetime.datetime.now(datetime.UTC)
if views_count is not None:
# Успешно получены просмотры
purchase.views_count = views_count
purchase.views_availability = domain.PostViewsAvailability.AVAILABLE
purchase.last_views_fetch_at = fetched_at
await self.database.update_purchase(purchase)
# Создаём снимок
snapshot = domain.ViewsSnapshot(
purchase_id=purchase.id,
views_count=views_count,
fetched_at=fetched_at,
)
await self.database.create_views_snapshot(snapshot)
log.info('Views fetched for purchase %s: %d', purchase.id, views_count)
return dto.FetchViewsOutput(
purchase_id=purchase.id,
views_count=views_count,
views_availability=domain.PostViewsAvailability.AVAILABLE,
fetched_at=fetched_at,
)
else:
# Не удалось получить просмотры
purchase.views_availability = domain.PostViewsAvailability.UNAVAILABLE
purchase.last_views_fetch_at = fetched_at
await self.database.update_purchase(purchase)
log.warning('Failed to fetch views for purchase %s', purchase.id)
return dto.FetchViewsOutput(
purchase_id=purchase.id,
views_count=None,
views_availability=domain.PostViewsAvailability.UNAVAILABLE,
fetched_at=fetched_at,
error_message='Failed to fetch views from Telegram API',
)

View File

@@ -0,0 +1,37 @@
import logging
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def get_views_history(self: 'Usecase', input: dto.GetViewsHistoryInput) -> dto.GetViewsHistoryOutput:
"""Получить историю просмотров для закупа."""
async with self.database.transaction():
purchase = await self.database.get_purchase(input.user_id, input.purchase_id)
if not purchase:
log.warning('Purchase %s not found for user %s', input.purchase_id, input.user_id)
raise domain.PurchaseNotFound(input.purchase_id)
snapshots = await self.database.get_views_history(
input.purchase_id,
from_date=input.from_date,
to_date=input.to_date,
)
return dto.GetViewsHistoryOutput(
snapshots=[
dto.ViewsSnapshotOutput(
id=snapshot.id,
purchase_id=snapshot.purchase_id,
views_count=snapshot.views_count,
fetched_at=snapshot.fetched_at,
created_at=snapshot.created_at,
)
for snapshot in snapshots
]
)

View File

@@ -0,0 +1,60 @@
import datetime
import logging
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def update_views_manually(self: 'Usecase', input: dto.UpdateViewsManuallyInput) -> dto.PurchaseOutput:
"""Ручное обновление просмотров для закупа."""
async with self.database.transaction():
purchase = await self.database.get_purchase(input.user_id, input.purchase_id)
if not purchase:
log.warning('Purchase %s not found for user %s', input.purchase_id, input.user_id)
raise domain.PurchaseNotFound(input.purchase_id)
fetched_at = datetime.datetime.now(datetime.UTC)
# Обновляем метрики закупа
purchase.views_count = input.views_count
purchase.views_availability = domain.PostViewsAvailability.MANUAL
purchase.last_views_fetch_at = fetched_at
await self.database.update_purchase(purchase)
# Создаём снимок
snapshot = domain.ViewsSnapshot(
purchase_id=purchase.id,
views_count=input.views_count,
fetched_at=fetched_at,
)
await self.database.create_views_snapshot(snapshot)
log.info('Views manually updated for purchase %s: %d', purchase.id, input.views_count)
# Возвращаем обновлённый закуп
return dto.PurchaseOutput(
id=purchase.id,
target_channel_id=purchase.target_channel_id,
target_channel_title=purchase.target_channel.title,
external_channel_id=purchase.external_channel_id,
external_channel_title=purchase.external_channel.title,
creative_id=purchase.creative_id,
creative_name=purchase.creative.name,
placement_date=purchase.placement_date,
cost=purchase.cost,
comment=purchase.comment,
ad_post_url=purchase.ad_post_url,
invite_link_type=purchase.invite_link_type,
invite_link=purchase.invite_link,
status=purchase.status,
subscriptions_count=purchase.subscriptions_count,
views_count=purchase.views_count,
views_availability=purchase.views_availability,
last_views_fetch_at=purchase.last_views_fetch_at,
created_at=purchase.created_at,
)