From e87958fb60d655328dbd0a900389c290d5f74de5 Mon Sep 17 00:00:00 2001 From: Artem Tsyrulnikov Date: Wed, 28 Jan 2026 12:58:00 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B8=D0=BD=D0=B2=D0=B0=D0=B9=D1=82=20=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BA=20=D0=B8=20=D0=BF=D0=BE=D0=B4=D1=81=D1=87?= =?UTF-8?q?=D0=B5=D1=82=20=D0=BE=D1=82=D0=BF=D0=B8=D1=81=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...add_invite_link_created_at_to_placement.py | 98 +++++++++++++++++++ src/adapter/postgres.py | 41 ++++++++ src/domain/placement.py | 1 + src/dto/analytics.py | 1 + src/dto/purchase.py | 1 + .../analytics/get_placements_analytics.py | 3 + .../purchase/build_placement_creative.py | 2 + 7 files changed, 147 insertions(+) create mode 100644 migrations/models/13_20260128125621_add_invite_link_created_at_to_placement.py diff --git a/migrations/models/13_20260128125621_add_invite_link_created_at_to_placement.py b/migrations/models/13_20260128125621_add_invite_link_created_at_to_placement.py new file mode 100644 index 0000000..f4150e5 --- /dev/null +++ b/migrations/models/13_20260128125621_add_invite_link_created_at_to_placement.py @@ -0,0 +1,98 @@ +# ruff: noqa +# mypy: ignore-errors +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "placement" ADD "invite_link_created_at" TIMESTAMPTZ;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "placement" DROP COLUMN "invite_link_created_at";""" + + +MODELS_STATE = ( + "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" +) diff --git a/src/adapter/postgres.py b/src/adapter/postgres.py index a3d6f83..6587cd0 100644 --- a/src/adapter/postgres.py +++ b/src/adapter/postgres.py @@ -733,6 +733,47 @@ class Postgres(DatabaseBase): counts = {row['placement_id']: row['count'] for row in results} return {pid: counts.get(pid, 0) for pid in placement_ids} + @staticmethod + async def count_unsubscriptions_by_placement_post_batch( + placement_post_ids: list[uuid.UUID], + ) -> dict[uuid.UUID, int]: + """Count unsubscriptions for placement_posts by finding their parent placements.""" + if not placement_post_ids: + return {} + + from tortoise.functions import Count + + # Get placement_ids from placement_posts + placement_posts = await domain.PlacementPost.filter(id__in=placement_post_ids).all() + placement_id_to_post_ids: dict[uuid.UUID, list[uuid.UUID]] = {} + for pp in placement_posts: + placement_id_to_post_ids.setdefault(pp.placement_id, []).append(pp.id) + + placement_ids = list(placement_id_to_post_ids.keys()) + if not placement_ids: + return dict.fromkeys(placement_post_ids, 0) + + # Count unsubscriptions by placement (filter by UNSUBSCRIBED status) + results = ( + await domain.Subscription.filter( + placement_id__in=placement_ids, status=domain.SubscriptionStatus.UNSUBSCRIBED + ) + .group_by('placement_id') + .annotate(count=Count('id')) + .values('placement_id', 'count') + ) + + placement_counts = {row['placement_id']: row['count'] for row in results} + + # Map back to placement_post_ids + post_counts: dict[uuid.UUID, int] = {} + for placement_id, post_ids in placement_id_to_post_ids.items(): + count = placement_counts.get(placement_id, 0) + for post_id in post_ids: + post_counts[post_id] = count + + return {pid: post_counts.get(pid, 0) for pid in placement_post_ids} + @staticmethod async def has_placement_posts_for_creative(creative_id: uuid.UUID) -> bool: return await domain.PlacementPost.filter(placement__creative_id=creative_id).exists() diff --git a/src/domain/placement.py b/src/domain/placement.py index d7ef224..c4ab37c 100644 --- a/src/domain/placement.py +++ b/src/domain/placement.py @@ -54,6 +54,7 @@ class Placement(TimestampedModel): comment = fields.TextField(null=True) invite_link = fields.CharField(max_length=512, null=True) + invite_link_created_at = fields.DatetimeField(null=True) invite_link_type = fields.CharEnumField(InviteLinkType) project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField( diff --git a/src/dto/analytics.py b/src/dto/analytics.py index b795089..59e29ae 100644 --- a/src/dto/analytics.py +++ b/src/dto/analytics.py @@ -47,6 +47,7 @@ class PlacementAnalyticsOutput(pydantic.BaseModel): placement_date: datetime.datetime cost: float | None subscriptions_count: int + unsubscriptions_count: int views_count: int | None cpf: float | None cpm: float | None diff --git a/src/dto/purchase.py b/src/dto/purchase.py index 28de07b..eae6708 100644 --- a/src/dto/purchase.py +++ b/src/dto/purchase.py @@ -30,6 +30,7 @@ class PlacementOutput(pydantic.BaseModel): creative_id: uuid.UUID | None = None comment: str | None = None invite_link: str | None + invite_link_created_at: datetime.datetime | None = None invite_link_type: InviteLinkType channel: ChannelOutput details: PlacementDetails | None = None diff --git a/src/usecase/analytics/get_placements_analytics.py b/src/usecase/analytics/get_placements_analytics.py index 28cb174..269ae27 100644 --- a/src/usecase/analytics/get_placements_analytics.py +++ b/src/usecase/analytics/get_placements_analytics.py @@ -72,6 +72,7 @@ async def get_placements_analytics( # Batch fetch subscriptions counts placement_ids = [p.id for p in placements] subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_ids) + unsubscriptions_counts = await self.database.count_unsubscriptions_by_placement_post_batch(placement_ids) results: list[dto.PlacementAnalyticsOutput] = [] for placement_post in placements: @@ -92,6 +93,7 @@ async def get_placements_analytics( cost = _get_cost(placement_post) subs_count = 0 if hide_subscriptions else subscriptions_counts.get(placement_post.id, 0) + unsubs_count = 0 if hide_subscriptions else unsubscriptions_counts.get(placement_post.id, 0) if placement_post.post and placement_post.post.id in views_map: views_count = views_map[placement_post.post.id][0] else: @@ -109,6 +111,7 @@ async def get_placements_analytics( placement_date=_get_placement_date(placement_post), cost=cost, subscriptions_count=subs_count, + unsubscriptions_count=unsubs_count, views_count=views_count, cpf=cpf, cpm=_calculate_cpm(cost, views_count), diff --git a/src/usecase/purchase/build_placement_creative.py b/src/usecase/purchase/build_placement_creative.py index 08a0048..8d771ac 100644 --- a/src/usecase/purchase/build_placement_creative.py +++ b/src/usecase/purchase/build_placement_creative.py @@ -4,6 +4,7 @@ import uuid from typing import TYPE_CHECKING from aiogram.types import InlineKeyboardButton +from tortoise import timezone from src import domain, dto @@ -87,6 +88,7 @@ async def build_placement_creative( requires_approval = placement.invite_link_type == domain.InviteLinkType.APPROVAL invite_link = await self.telegram_bot.create_chat_invite_link(project.channel.telegram_id, requires_approval) placement.invite_link = invite_link + placement.invite_link_created_at = timezone.now() await self.database.update_placement(placement) invite_link = placement.invite_link