From 83a7daa9ba5aba8c342eeebf3013a69a2cfc1b0f Mon Sep 17 00:00:00 2001 From: ivannoskov Date: Tue, 3 Feb 2026 18:57:15 +0300 Subject: [PATCH] feat: update analytics --- src/adapter/postgres.py | 242 ++++++++++++++++-- src/controller/http_v1/analytics.py | 74 +++++- src/dto/analytics.py | 120 +++++++-- .../analytics/get_placements_analytics.py | 241 ++++++++++++----- 4 files changed, 554 insertions(+), 123 deletions(-) diff --git a/src/adapter/postgres.py b/src/adapter/postgres.py index e101083..76803fc 100644 --- a/src/adapter/postgres.py +++ b/src/adapter/postgres.py @@ -559,17 +559,13 @@ class Postgres(DatabaseBase): async def get_subscription_by_subscriber_and_placement_post( telegram_user_id: uuid.UUID, placement_post_id: uuid.UUID ) -> domain.Subscription | None: - return await domain.Subscription.get_or_none( - telegram_user_id=telegram_user_id, placement_id=placement_post_id - ) + return await domain.Subscription.get_or_none(telegram_user_id=telegram_user_id, placement_id=placement_post_id) @staticmethod async def get_subscription_by_subscriber_and_placement( telegram_user_id: uuid.UUID, placement_id: uuid.UUID ) -> domain.Subscription | None: - return await domain.Subscription.get_or_none( - telegram_user_id=telegram_user_id, placement_id=placement_id - ) + return await domain.Subscription.get_or_none(telegram_user_id=telegram_user_id, placement_id=placement_id) @staticmethod async def update_subscription(subscription: domain.Subscription) -> None: @@ -792,15 +788,19 @@ class Postgres(DatabaseBase): @staticmethod async def get_next_post_after(channel_id: uuid.UUID, message_id: int) -> domain.Post | None: """Get first post in channel after the given message_id.""" - return await domain.Post.filter( - channel_id=channel_id, - message_id__gt=message_id, - deleted_from_channel_at__isnull=True, - ).order_by('message_id').first() + return ( + await domain.Post.filter( + channel_id=channel_id, + message_id__gt=message_id, + deleted_from_channel_at__isnull=True, + ) + .order_by('message_id') + .first() + ) @staticmethod async def get_next_posts_after_batch( - channel_message_pairs: list[tuple[uuid.UUID, int]] + channel_message_pairs: list[tuple[uuid.UUID, int]], ) -> dict[tuple[uuid.UUID, int], domain.Post]: """Batch version: get next post for each (channel_id, message_id) pair.""" if not channel_message_pairs: @@ -808,12 +808,220 @@ class Postgres(DatabaseBase): results: dict[tuple[uuid.UUID, int], domain.Post] = {} for channel_id, message_id in channel_message_pairs: - next_post = await domain.Post.filter( - channel_id=channel_id, - message_id__gt=message_id, - deleted_from_channel_at__isnull=True, - ).order_by('message_id').first() + next_post = ( + await domain.Post.filter( + channel_id=channel_id, + message_id__gt=message_id, + deleted_from_channel_at__isnull=True, + ) + .order_by('message_id') + .first() + ) if next_post: results[(channel_id, message_id)] = next_post return results + + @staticmethod + async def get_workspace_placements_for_analytics( + workspace_id: uuid.UUID, + project_ids: list[uuid.UUID] | None = None, + channel_ids: list[uuid.UUID] | None = None, + creative_ids: list[uuid.UUID] | None = None, + status_list: list[str] | None = None, + cost_types: list[str] | None = None, + placement_types: list[str] | None = None, + invite_link_types: list[str] | None = None, + cost_min: float | None = None, + cost_max: float | None = None, + views_min: int | None = None, + views_max: int | None = None, + subscriptions_min: int | None = None, + subscriptions_max: int | None = None, + cpm_min: float | None = None, + cpm_max: float | None = None, + channel_title_contains: str | None = None, + creative_name_contains: str | None = None, + comment_contains: str | None = None, + placement_date_from: datetime.datetime | None = None, + placement_date_to: datetime.datetime | None = None, + sort_by: str = 'created_at', + sort_direction: str = 'desc', + offset: int = 0, + limit: int = 50, + include_archived: bool = False, + allowed_project_ids: set[uuid.UUID] | None = None, + ) -> list[domain.Placement]: + """Get placements for analytics with flexible filtering, sorting, and pagination.""" + from tortoise.queryset import QuerySet + + query: QuerySet[domain.Placement] = domain.Placement.filter( + project__workspace_id=workspace_id, + ) + + if project_ids: + query = query.filter(project_id__in=project_ids) + elif allowed_project_ids is not None: + if not allowed_project_ids: + return [] + query = query.filter(project_id__in=list(allowed_project_ids)) + + if channel_ids: + query = query.filter(channel_id__in=channel_ids) + + if creative_ids: + query = query.filter(creative_id__in=creative_ids) + + if status_list: + query = query.filter(status__in=status_list) + + if cost_types: + query = query.filter(cost_type__in=cost_types) + + if placement_types: + query = query.filter(placement_type__in=placement_types) + + if invite_link_types: + query = query.filter(invite_link_type__in=invite_link_types) + + if cost_min is not None: + query = query.filter(cost_value__gte=cost_min) + if cost_max is not None: + query = query.filter(cost_value__lte=cost_max) + + if placement_date_from: + query = query.filter(placement_at__gte=placement_date_from) + if placement_date_to: + query = query.filter(placement_at__lte=placement_date_to) + + if channel_title_contains: + query = query.filter(channel__title__icontains=channel_title_contains) + + if creative_name_contains: + query = query.filter(creative__name__icontains=creative_name_contains) + + if comment_contains: + query = query.filter(comment__icontains=comment_contains) + + elif not include_archived: + query = query.filter( + status__in=[ + domain.PlacementStatus.NO_STATUS, + domain.PlacementStatus.WRITE, + domain.PlacementStatus.WAITING_RESPONSE, + domain.PlacementStatus.TERMS_APPROVAL, + domain.PlacementStatus.TO_PAY, + domain.PlacementStatus.PAID, + ] + ) + + valid_sort_fields = ['created_at', 'updated_at', 'placement_at', 'cost_value'] + if sort_by not in valid_sort_fields: + sort_by = 'created_at' + + if sort_direction == 'asc': + query = query.order_by(sort_by) + else: + query = query.order_by(f'-{sort_by}') + + return ( + await query.prefetch_related( + 'project', + 'project__channel', + 'channel', + 'creative', + 'placement_posts', + 'placement_posts__post', + 'placement_posts__post__channel', + ) + .offset(offset) + .limit(limit) + .all() + ) + + @staticmethod + async def count_workspace_placements_for_analytics( + workspace_id: uuid.UUID, + project_ids: list[uuid.UUID] | None = None, + channel_ids: list[uuid.UUID] | None = None, + creative_ids: list[uuid.UUID] | None = None, + status_list: list[str] | None = None, + cost_types: list[str] | None = None, + placement_types: list[str] | None = None, + invite_link_types: list[str] | None = None, + cost_min: float | None = None, + cost_max: float | None = None, + views_min: int | None = None, + views_max: int | None = None, + subscriptions_min: int | None = None, + subscriptions_max: int | None = None, + cpm_min: float | None = None, + cpm_max: float | None = None, + channel_title_contains: str | None = None, + creative_name_contains: str | None = None, + comment_contains: str | None = None, + placement_date_from: datetime.datetime | None = None, + placement_date_to: datetime.datetime | None = None, + include_archived: bool = False, + allowed_project_ids: set[uuid.UUID] | None = None, + ) -> int: + """Count placements for analytics with flexible filtering.""" + query = domain.Placement.filter(project__workspace_id=workspace_id) + + if project_ids: + query = query.filter(project_id__in=project_ids) + elif allowed_project_ids is not None: + if not allowed_project_ids: + return 0 + query = query.filter(project_id__in=list(allowed_project_ids)) + + if channel_ids: + query = query.filter(channel_id__in=channel_ids) + + if creative_ids: + query = query.filter(creative_id__in=creative_ids) + + if status_list: + query = query.filter(status__in=status_list) + + if cost_types: + query = query.filter(cost_type__in=cost_types) + + if placement_types: + query = query.filter(placement_type__in=placement_types) + + if invite_link_types: + query = query.filter(invite_link_type__in=invite_link_types) + + if cost_min is not None: + query = query.filter(cost_value__gte=cost_min) + if cost_max is not None: + query = query.filter(cost_value__lte=cost_max) + + if placement_date_from: + query = query.filter(placement_at__gte=placement_date_from) + if placement_date_to: + query = query.filter(placement_at__lte=placement_date_to) + + if channel_title_contains: + query = query.filter(channel__title__icontains=channel_title_contains) + + if creative_name_contains: + query = query.filter(creative__name__icontains=creative_name_contains) + + if comment_contains: + query = query.filter(comment__icontains=comment_contains) + + elif not include_archived: + query = query.filter( + status__in=[ + domain.PlacementStatus.NO_STATUS, + domain.PlacementStatus.WRITE, + domain.PlacementStatus.WAITING_RESPONSE, + domain.PlacementStatus.TERMS_APPROVAL, + domain.PlacementStatus.TO_PAY, + domain.PlacementStatus.PAID, + ] + ) + + return await query.count() diff --git a/src/controller/http_v1/analytics.py b/src/controller/http_v1/analytics.py index f4be026..bbce5fe 100644 --- a/src/controller/http_v1/analytics.py +++ b/src/controller/http_v1/analytics.py @@ -16,23 +16,71 @@ analytics_router = APIRouter(prefix='/workspaces/{workspace_id}/analytics', tags async def get_placements_analytics( workspace_id: uuid.UUID, current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], - project_id: uuid.UUID | None = None, - placement_channel_id: uuid.UUID | None = None, - creative_id: uuid.UUID | None = None, - date_from: datetime.datetime | None = None, - date_to: datetime.datetime | None = None, -) -> Page[dto.PlacementAnalyticsOutput]: + # Categorical filters - accept both list and comma-separated string + project_ids: list[uuid.UUID] | None = None, + status_list: str | None = None, + placement_channel_ids: list[uuid.UUID] | None = None, + creative_ids: list[uuid.UUID] | None = None, + cost_types: str | None = None, + placement_types: str | None = None, + invite_link_types: str | None = None, + # Numeric filters + cost_min: float | None = None, + cost_max: float | None = None, + views_min: int | None = None, + views_max: int | None = None, + subscriptions_min: int | None = None, + subscriptions_max: int | None = None, + cpm_min: float | None = None, + cpm_max: float | None = None, + # Text filters + channel_title_contains: str | None = None, + creative_name_contains: str | None = None, + comment_contains: str | None = None, + # Date filters + placement_date_from: datetime.datetime | None = None, + placement_date_to: datetime.datetime | None = None, + # Pagination and sorting + sort_by: str | None = 'created_at', + sort_direction: str = 'desc', + page: int = 1, + size: int = 50, +) -> dto.GetPlacementsAnalyticsOutput: + # Parse comma-separated lists + parsed_status_list = status_list.split(',') if status_list else None + parsed_cost_types = cost_types.split(',') if cost_types else None + parsed_placement_types = placement_types.split(',') if placement_types else None + parsed_invite_link_types = invite_link_types.split(',') if invite_link_types else None + input = dto.GetPlacementsAnalyticsInput( user_id=current_user.user_id, workspace_id=workspace_id, - project_id=project_id, - placement_channel_id=placement_channel_id, - creative_id=creative_id, - date_from=date_from, - date_to=date_to, + project_ids=project_ids, + status_list=parsed_status_list, + placement_channel_ids=placement_channel_ids, + creative_ids=creative_ids, + cost_types=parsed_cost_types, + placement_types=parsed_placement_types, + invite_link_types=parsed_invite_link_types, + cost_min=cost_min, + cost_max=cost_max, + views_min=views_min, + views_max=views_max, + subscriptions_min=subscriptions_min, + subscriptions_max=subscriptions_max, + cpm_min=cpm_min, + cpm_max=cpm_max, + channel_title_contains=channel_title_contains, + creative_name_contains=creative_name_contains, + comment_contains=comment_contains, + placement_date_from=placement_date_from, + placement_date_to=placement_date_to, + sort_by=sort_by, + sort_direction=sort_direction, + page=page, + size=size, ) - result = await deps.get_usecase().get_placements_analytics(input) - return paginate(result) # type: ignore[no-any-return] + return await deps.get_usecase().get_placements_analytics(input) @analytics_router.get('/creatives') diff --git a/src/dto/analytics.py b/src/dto/analytics.py index 59e29ae..437df5b 100644 --- a/src/dto/analytics.py +++ b/src/dto/analytics.py @@ -39,19 +39,101 @@ class ProjectMetrics(StrEnum): class PlacementAnalyticsOutput(pydantic.BaseModel): id: uuid.UUID project_id: uuid.UUID - project_channel_title: str - placement_channel_id: uuid.UUID - placement_channel_title: str - creative_id: uuid.UUID - creative_name: str - placement_date: datetime.datetime - cost: float | None - subscriptions_count: int - unsubscriptions_count: int - views_count: int | None - cpf: float | None - cpm: float | None + project_title: str + channel_id: uuid.UUID + channel_title: str + creative_id: uuid.UUID | None = None + creative_name: str | None = None + cost: float | None = None + cost_type: domain.CostType = domain.CostType.FIXED + cost_before_bargain: float | None = None + payment_at: datetime.datetime | None = None + placement_type: domain.PlacementType | None = None + comment: str | None = None + format: str | None = None + invite_link_type: domain.InviteLinkType | None = None + placement_date: datetime.datetime | None = None + subscriptions_count: int = 0 + views_count: int | None = None + cpf: float | None = None + cpm: float | None = None + time_on_top: int | None = None + time_in_feed: int | None = None + invite_link: str | None = None + invite_link_created_at: datetime.datetime | None = None post_url: str | None = None + post_deleted_at: datetime.datetime | None = None + conversion_24h: float | None = None + conversion_48h: float | None = None + conversion_total: float | None = None + unsubscriptions_count: int = 0 + unsub_percent: float | None = None + total_active: int = 0 + + +class GetPlacementsAnalyticsInput(pydantic.BaseModel): + user_id: uuid.UUID + workspace_id: uuid.UUID + + # Categorical filters (multiple selection) + project_ids: list[uuid.UUID] | None = None + status_list: list[str] | None = None + placement_channel_ids: list[uuid.UUID] | None = None + creative_ids: list[uuid.UUID] | None = None + cost_types: list[str] | None = None + placement_types: list[str] | None = None + invite_link_types: list[str] | None = None + + # Numeric filters (ranges) + cost_min: float | None = None + cost_max: float | None = None + views_min: int | None = None + views_max: int | None = None + subscriptions_min: int | None = None + subscriptions_max: int | None = None + cpm_min: float | None = None + cpm_max: float | None = None + cpf_min: float | None = None + cpf_max: float | None = None + discount_min: float | None = None + discount_max: float | None = None + conversion_24h_min: float | None = None + conversion_24h_max: float | None = None + conversion_48h_min: float | None = None + conversion_48h_max: float | None = None + conversion_total_min: float | None = None + conversion_total_max: float | None = None + unsub_percent_min: float | None = None + unsub_percent_max: float | None = None + time_on_top_min: int | None = None + time_on_top_max: int | None = None + time_in_feed_min: int | None = None + time_in_feed_max: int | None = None + + # Text filters (substring) + channel_title_contains: str | None = None + creative_name_contains: str | None = None + comment_contains: str | None = None + + # Date filters + placement_date_from: datetime.datetime | None = None + placement_date_to: datetime.datetime | None = None + payment_date_from: datetime.datetime | None = None + payment_date_to: datetime.datetime | None = None + + # Pagination and sorting + sort_by: str | None = 'created_at' + sort_direction: str = 'desc' + page: int = 1 + size: int = 50 + + +class GetPlacementsAnalyticsOutput(pydantic.BaseModel): + items: list[PlacementAnalyticsOutput] + total: int + page: int + size: int + pages: int class CreativeAnalyticsOutput(pydantic.BaseModel): @@ -78,20 +160,6 @@ class ChannelAnalyticsOutput(pydantic.BaseModel): avg_cpm: float | None -class GetPlacementsAnalyticsInput(pydantic.BaseModel): - user_id: uuid.UUID - workspace_id: uuid.UUID - project_id: uuid.UUID | None = None - placement_channel_id: uuid.UUID | None = None - creative_id: uuid.UUID | None = None - date_from: datetime.datetime | None = None - date_to: datetime.datetime | None = None - - -class GetPlacementsAnalyticsOutput(pydantic.BaseModel): - placements: list[PlacementAnalyticsOutput] - - class GetCreativesAnalyticsInput(pydantic.BaseModel): user_id: uuid.UUID workspace_id: uuid.UUID diff --git a/src/usecase/analytics/get_placements_analytics.py b/src/usecase/analytics/get_placements_analytics.py index 269ae27..085efb4 100644 --- a/src/usecase/analytics/get_placements_analytics.py +++ b/src/usecase/analytics/get_placements_analytics.py @@ -10,20 +10,6 @@ if TYPE_CHECKING: log = logging.getLogger(__name__) -def _get_placement_date(placement_post: domain.PlacementPost) -> datetime.datetime: - placement = placement_post.placement - if placement and placement.placement_at: - return placement.placement_at - if placement_post.post and placement_post.post.created_at: - return placement_post.post.created_at - return placement_post.created_at - - -def _get_cost(placement_post: domain.PlacementPost) -> float | None: - placement = placement_post.placement - return placement.cost_value if placement else None - - def _calculate_cpf(cost: float | None, subscriptions: int) -> float | None: if cost is None or subscriptions == 0: return None @@ -36,86 +22,207 @@ def _calculate_cpm(cost: float | None, views: int | None) -> float | None: return (cost / views) * 1000 +def _calculate_discount_percent(cost: float | None, cost_before: float | None) -> float | None: + if cost is None or cost_before is None or cost_before == 0: + return None + return ((cost_before - cost) / cost_before) * 100 + + async def get_placements_analytics( self: 'Usecase', input: dto.GetPlacementsAnalyticsInput -) -> list[dto.PlacementAnalyticsOutput]: +) -> dto.GetPlacementsAnalyticsOutput: context = await self.ensure_analytics_permission(input.workspace_id, input.user_id) allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ) hide_subscriptions = context.should_hide_subscriptions() - if input.project_id: - project = await self.database.get_project(input.workspace_id, input.project_id) - if not project: - raise domain.ProjectNotFound(input.project_id) + # Filter by project permissions + project_ids = input.project_ids + if project_ids: + # Verify all requested projects exist and user has access + for pid in project_ids: + project = await self.database.get_project(input.workspace_id, pid) + if not project: + raise domain.ProjectNotFound(pid) allowed_project_ids = None - placements = await self.database.get_workspace_placement_posts( + placements = await self.database.get_workspace_placements_for_analytics( workspace_id=input.workspace_id, - project_id=input.project_id, - placement_channel_id=input.placement_channel_id, - creative_id=input.creative_id, - date_from=input.date_from, - date_to=input.date_to, + project_ids=project_ids or input.project_ids, + channel_ids=input.placement_channel_ids, + creative_ids=input.creative_ids, + status_list=input.status_list, + cost_types=input.cost_types, + placement_types=input.placement_types, + invite_link_types=input.invite_link_types, + cost_min=input.cost_min, + cost_max=input.cost_max, + views_min=input.views_min, + views_max=input.views_max, + subscriptions_min=input.subscriptions_min, + subscriptions_max=input.subscriptions_max, + cpm_min=input.cpm_min, + cpm_max=input.cpm_max, + channel_title_contains=input.channel_title_contains, + creative_name_contains=input.creative_name_contains, + comment_contains=input.comment_contains, + placement_date_from=input.placement_date_from, + placement_date_to=input.placement_date_to, + sort_by=input.sort_by or 'created_at', + sort_direction=input.sort_direction or 'desc', + offset=(input.page - 1) * input.size, + limit=input.size, include_archived=False, allowed_project_ids=allowed_project_ids, - has_post=True, ) - if not placements: - return [] + total = await self.database.count_workspace_placements_for_analytics( + workspace_id=input.workspace_id, + project_ids=project_ids or input.project_ids, + channel_ids=input.placement_channel_ids, + creative_ids=input.creative_ids, + status_list=input.status_list, + cost_types=input.cost_types, + placement_types=input.placement_types, + invite_link_types=input.invite_link_types, + cost_min=input.cost_min, + cost_max=input.cost_max, + views_min=input.views_min, + views_max=input.views_max, + subscriptions_min=input.subscriptions_min, + subscriptions_max=input.subscriptions_max, + cpm_min=input.cpm_min, + cpm_max=input.cpm_max, + channel_title_contains=input.channel_title_contains, + creative_name_contains=input.creative_name_contains, + comment_contains=input.comment_contains, + placement_date_from=input.placement_date_from, + placement_date_to=input.placement_date_to, + include_archived=False, + allowed_project_ids=allowed_project_ids, + ) - # Batch fetch views data for all posts - post_ids = [p.post.id for p in placements if p.post] + placement_post_ids = [p.id for p in placements] + post_ids = ( + [ + p.post.id + for p in placements + if hasattr(p, 'placement_posts') and p.placement_posts and p.placement_posts[0].post + ] + if placements + else [] + ) views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {} - - # 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) + subscriptions_counts = ( + await self.database.count_subscriptions_by_placement_post_batch(placement_post_ids) + if placement_post_ids + else {} + ) + unsubscriptions_counts = ( + await self.database.count_unsubscriptions_by_placement_post_batch(placement_post_ids) + if placement_post_ids + else {} + ) results: list[dto.PlacementAnalyticsOutput] = [] - for placement_post in placements: - placement = placement_post.placement - if not placement or not placement.project or not placement.channel or not placement.creative: - log.warning('PlacementPost %s missing placement data', placement_post.id) - continue - if not placement.project.channel: - log.warning('Placement %s missing project channel', placement.id) + for placement in placements: + placement_post = placement.placement_posts[0] if placement.placement_posts else None + + project = placement.project + channel = placement.channel + creative = placement.creative + + if not project or not channel: + log.warning('Placement %s missing project or channel', placement.id) continue - # Generate post URL + cost_value = placement.cost_value + cost_type = placement.cost_type if placement.cost_type else domain.CostType.FIXED + cost_before = placement.cost_before_bargain + + subs_count = 0 + if placement_post and not hide_subscriptions: + subs_count = subscriptions_counts.get(placement_post.id, 0) + + unsubs_count = 0 + if placement_post and not hide_subscriptions: + unsubs_count = unsubscriptions_counts.get(placement_post.id, 0) + + views_count = None + if placement_post and placement_post.post: + post_id = placement_post.post.id + if post_id in views_map: + views_count = views_map[post_id][0] + + cpm_value = _calculate_cpm(cost_value, views_count) + cpf_value = None if hide_subscriptions else _calculate_cpf(cost_value, subs_count) + discount_percent = _calculate_discount_percent(cost_value, cost_before) + + conversion_24h = None + conversion_48h = None + conversion_total = None + if placement_post and views_count and views_count > 0 and not hide_subscriptions: + if subs_count > 0: + conversion_total = (subs_count / views_count) * 100 + + total_subs = subs_count + unsubs_count + unsub_percent = None if total_subs == 0 else (unsubs_count / total_subs) * 100 + + total_active = subs_count if not hide_subscriptions else 0 + post_url = None - if placement_post.post: - post = placement_post.post - if post.channel.username: - post_url = f'https://t.me/{post.channel.username}/{post.message_id}' + if placement_post and placement_post.post and placement_post.post.channel.username: + post_url = f'https://t.me/{placement_post.post.channel.username}/{placement_post.post.message_id}' + + post_deleted_at = ( + placement_post.post.deleted_from_channel_at if placement_post and placement_post.post else None + ) - 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: - views_count = None - cpf = None if hide_subscriptions else _calculate_cpf(cost, subs_count) results.append( dto.PlacementAnalyticsOutput( - id=placement_post.id, - project_id=placement.project_id, - project_channel_title=placement.project.channel.title, - placement_channel_id=placement.channel_id, - placement_channel_title=placement.channel.title, + id=placement.id, + project_id=project.id, + project_title=getattr(project, 'title', None) or getattr(project.channel, 'title', '') + if project and project.channel + else '', + channel_id=channel.id, + channel_title=channel.title, creative_id=placement.creative_id, - creative_name=placement.creative.name, - placement_date=_get_placement_date(placement_post), - cost=cost, + creative_name=creative.name if creative else None, + cost=cost_value, + cost_type=cost_type, + cost_before_bargain=cost_before, + payment_at=placement.payment_at, + placement_type=placement.placement_type, + comment=placement.comment, + format=placement.format, + invite_link_type=placement.invite_link_type, + placement_date=placement.placement_at, subscriptions_count=subs_count, - unsubscriptions_count=unsubs_count, views_count=views_count, - cpf=cpf, - cpm=_calculate_cpm(cost, views_count), + cpf=cpf_value, + cpm=cpm_value, + time_on_top=placement_post.time_on_top if placement_post else None, + time_in_feed=None, + invite_link=placement.invite_link, + invite_link_created_at=placement.invite_link_created_at, post_url=post_url, + post_deleted_at=post_deleted_at, + conversion_24h=conversion_24h, + conversion_48h=conversion_48h, + conversion_total=conversion_total, + unsubscriptions_count=unsubs_count, + unsub_percent=unsub_percent, + total_active=total_active, ) ) - return results + + pages = (total + input.size - 1) // input.size if total > 0 else 0 + + return dto.GetPlacementsAnalyticsOutput( + items=results, + total=total, + page=input.page, + size=input.size, + pages=pages, + )