Merge pull request #4 from TelegramExchange/analytics-update

feat: update analytics
This commit is contained in:
Artem
2026-02-03 18:58:52 +03:00
committed by GitHub
4 changed files with 554 additions and 123 deletions

View File

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

View File

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

View File

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

View File

@@ -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)
# 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(input.project_id)
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
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}'
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)
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)
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 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
)
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,
)