инвайт линк и подсчет отписок

This commit is contained in:
Artem Tsyrulnikov
2026-01-28 12:58:00 +03:00
parent 632fa4b971
commit e87958fb60
7 changed files with 147 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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