Merge pull request #4 from TelegramExchange/analytics-update
feat: update analytics
This commit is contained in:
@@ -559,17 +559,13 @@ class Postgres(DatabaseBase):
|
|||||||
async def get_subscription_by_subscriber_and_placement_post(
|
async def get_subscription_by_subscriber_and_placement_post(
|
||||||
telegram_user_id: uuid.UUID, placement_post_id: uuid.UUID
|
telegram_user_id: uuid.UUID, placement_post_id: uuid.UUID
|
||||||
) -> domain.Subscription | None:
|
) -> domain.Subscription | None:
|
||||||
return await domain.Subscription.get_or_none(
|
return await domain.Subscription.get_or_none(telegram_user_id=telegram_user_id, placement_id=placement_post_id)
|
||||||
telegram_user_id=telegram_user_id, placement_id=placement_post_id
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_subscription_by_subscriber_and_placement(
|
async def get_subscription_by_subscriber_and_placement(
|
||||||
telegram_user_id: uuid.UUID, placement_id: uuid.UUID
|
telegram_user_id: uuid.UUID, placement_id: uuid.UUID
|
||||||
) -> domain.Subscription | None:
|
) -> domain.Subscription | None:
|
||||||
return await domain.Subscription.get_or_none(
|
return await domain.Subscription.get_or_none(telegram_user_id=telegram_user_id, placement_id=placement_id)
|
||||||
telegram_user_id=telegram_user_id, placement_id=placement_id
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def update_subscription(subscription: domain.Subscription) -> None:
|
async def update_subscription(subscription: domain.Subscription) -> None:
|
||||||
@@ -792,15 +788,19 @@ class Postgres(DatabaseBase):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_next_post_after(channel_id: uuid.UUID, message_id: int) -> domain.Post | None:
|
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."""
|
"""Get first post in channel after the given message_id."""
|
||||||
return await domain.Post.filter(
|
return (
|
||||||
|
await domain.Post.filter(
|
||||||
channel_id=channel_id,
|
channel_id=channel_id,
|
||||||
message_id__gt=message_id,
|
message_id__gt=message_id,
|
||||||
deleted_from_channel_at__isnull=True,
|
deleted_from_channel_at__isnull=True,
|
||||||
).order_by('message_id').first()
|
)
|
||||||
|
.order_by('message_id')
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_next_posts_after_batch(
|
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]:
|
) -> dict[tuple[uuid.UUID, int], domain.Post]:
|
||||||
"""Batch version: get next post for each (channel_id, message_id) pair."""
|
"""Batch version: get next post for each (channel_id, message_id) pair."""
|
||||||
if not channel_message_pairs:
|
if not channel_message_pairs:
|
||||||
@@ -808,12 +808,220 @@ class Postgres(DatabaseBase):
|
|||||||
|
|
||||||
results: dict[tuple[uuid.UUID, int], domain.Post] = {}
|
results: dict[tuple[uuid.UUID, int], domain.Post] = {}
|
||||||
for channel_id, message_id in channel_message_pairs:
|
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,
|
channel_id=channel_id,
|
||||||
message_id__gt=message_id,
|
message_id__gt=message_id,
|
||||||
deleted_from_channel_at__isnull=True,
|
deleted_from_channel_at__isnull=True,
|
||||||
).order_by('message_id').first()
|
)
|
||||||
|
.order_by('message_id')
|
||||||
|
.first()
|
||||||
|
)
|
||||||
if next_post:
|
if next_post:
|
||||||
results[(channel_id, message_id)] = next_post
|
results[(channel_id, message_id)] = next_post
|
||||||
|
|
||||||
return results
|
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()
|
||||||
|
|||||||
@@ -16,23 +16,71 @@ analytics_router = APIRouter(prefix='/workspaces/{workspace_id}/analytics', tags
|
|||||||
async def get_placements_analytics(
|
async def get_placements_analytics(
|
||||||
workspace_id: uuid.UUID,
|
workspace_id: uuid.UUID,
|
||||||
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
||||||
project_id: uuid.UUID | None = None,
|
# Categorical filters - accept both list and comma-separated string
|
||||||
placement_channel_id: uuid.UUID | None = None,
|
project_ids: list[uuid.UUID] | None = None,
|
||||||
creative_id: uuid.UUID | None = None,
|
status_list: str | None = None,
|
||||||
date_from: datetime.datetime | None = None,
|
placement_channel_ids: list[uuid.UUID] | None = None,
|
||||||
date_to: datetime.datetime | None = None,
|
creative_ids: list[uuid.UUID] | None = None,
|
||||||
) -> Page[dto.PlacementAnalyticsOutput]:
|
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(
|
input = dto.GetPlacementsAnalyticsInput(
|
||||||
user_id=current_user.user_id,
|
user_id=current_user.user_id,
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
project_id=project_id,
|
project_ids=project_ids,
|
||||||
placement_channel_id=placement_channel_id,
|
status_list=parsed_status_list,
|
||||||
creative_id=creative_id,
|
placement_channel_ids=placement_channel_ids,
|
||||||
date_from=date_from,
|
creative_ids=creative_ids,
|
||||||
date_to=date_to,
|
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 await deps.get_usecase().get_placements_analytics(input)
|
||||||
return paginate(result) # type: ignore[no-any-return]
|
|
||||||
|
|
||||||
|
|
||||||
@analytics_router.get('/creatives')
|
@analytics_router.get('/creatives')
|
||||||
|
|||||||
@@ -39,19 +39,101 @@ class ProjectMetrics(StrEnum):
|
|||||||
class PlacementAnalyticsOutput(pydantic.BaseModel):
|
class PlacementAnalyticsOutput(pydantic.BaseModel):
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
project_id: uuid.UUID
|
project_id: uuid.UUID
|
||||||
project_channel_title: str
|
project_title: str
|
||||||
placement_channel_id: uuid.UUID
|
channel_id: uuid.UUID
|
||||||
placement_channel_title: str
|
channel_title: str
|
||||||
creative_id: uuid.UUID
|
creative_id: uuid.UUID | None = None
|
||||||
creative_name: str
|
creative_name: str | None = None
|
||||||
placement_date: datetime.datetime
|
cost: float | None = None
|
||||||
cost: float | None
|
cost_type: domain.CostType = domain.CostType.FIXED
|
||||||
subscriptions_count: int
|
cost_before_bargain: float | None = None
|
||||||
unsubscriptions_count: int
|
payment_at: datetime.datetime | None = None
|
||||||
views_count: int | None
|
placement_type: domain.PlacementType | None = None
|
||||||
cpf: float | None
|
comment: str | None = None
|
||||||
cpm: float | 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_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):
|
class CreativeAnalyticsOutput(pydantic.BaseModel):
|
||||||
@@ -78,20 +160,6 @@ class ChannelAnalyticsOutput(pydantic.BaseModel):
|
|||||||
avg_cpm: float | None
|
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):
|
class GetCreativesAnalyticsInput(pydantic.BaseModel):
|
||||||
user_id: uuid.UUID
|
user_id: uuid.UUID
|
||||||
workspace_id: uuid.UUID
|
workspace_id: uuid.UUID
|
||||||
|
|||||||
@@ -10,20 +10,6 @@ if TYPE_CHECKING:
|
|||||||
log = logging.getLogger(__name__)
|
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:
|
def _calculate_cpf(cost: float | None, subscriptions: int) -> float | None:
|
||||||
if cost is None or subscriptions == 0:
|
if cost is None or subscriptions == 0:
|
||||||
return None
|
return None
|
||||||
@@ -36,86 +22,207 @@ def _calculate_cpm(cost: float | None, views: int | None) -> float | None:
|
|||||||
return (cost / views) * 1000
|
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(
|
async def get_placements_analytics(
|
||||||
self: 'Usecase', input: dto.GetPlacementsAnalyticsInput
|
self: 'Usecase', input: dto.GetPlacementsAnalyticsInput
|
||||||
) -> list[dto.PlacementAnalyticsOutput]:
|
) -> dto.GetPlacementsAnalyticsOutput:
|
||||||
context = await self.ensure_analytics_permission(input.workspace_id, input.user_id)
|
context = await self.ensure_analytics_permission(input.workspace_id, input.user_id)
|
||||||
|
|
||||||
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ)
|
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ)
|
||||||
hide_subscriptions = context.should_hide_subscriptions()
|
hide_subscriptions = context.should_hide_subscriptions()
|
||||||
|
|
||||||
if input.project_id:
|
# Filter by project permissions
|
||||||
project = await self.database.get_project(input.workspace_id, input.project_id)
|
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:
|
if not project:
|
||||||
raise domain.ProjectNotFound(input.project_id)
|
raise domain.ProjectNotFound(pid)
|
||||||
allowed_project_ids = None
|
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,
|
workspace_id=input.workspace_id,
|
||||||
project_id=input.project_id,
|
project_ids=project_ids or input.project_ids,
|
||||||
placement_channel_id=input.placement_channel_id,
|
channel_ids=input.placement_channel_ids,
|
||||||
creative_id=input.creative_id,
|
creative_ids=input.creative_ids,
|
||||||
date_from=input.date_from,
|
status_list=input.status_list,
|
||||||
date_to=input.date_to,
|
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,
|
include_archived=False,
|
||||||
allowed_project_ids=allowed_project_ids,
|
allowed_project_ids=allowed_project_ids,
|
||||||
has_post=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not placements:
|
total = await self.database.count_workspace_placements_for_analytics(
|
||||||
return []
|
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
|
placement_post_ids = [p.id for p in placements]
|
||||||
post_ids = [p.post.id for p in placements if p.post]
|
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 {}
|
views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {}
|
||||||
|
subscriptions_counts = (
|
||||||
# Batch fetch subscriptions counts
|
await self.database.count_subscriptions_by_placement_post_batch(placement_post_ids)
|
||||||
placement_ids = [p.id for p in placements]
|
if placement_post_ids
|
||||||
subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_ids)
|
else {}
|
||||||
unsubscriptions_counts = await self.database.count_unsubscriptions_by_placement_post_batch(placement_ids)
|
)
|
||||||
|
unsubscriptions_counts = (
|
||||||
|
await self.database.count_unsubscriptions_by_placement_post_batch(placement_post_ids)
|
||||||
|
if placement_post_ids
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
|
||||||
results: list[dto.PlacementAnalyticsOutput] = []
|
results: list[dto.PlacementAnalyticsOutput] = []
|
||||||
for placement_post in placements:
|
for placement in placements:
|
||||||
placement = placement_post.placement
|
placement_post = placement.placement_posts[0] if placement.placement_posts else None
|
||||||
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)
|
project = placement.project
|
||||||
continue
|
channel = placement.channel
|
||||||
if not placement.project.channel:
|
creative = placement.creative
|
||||||
log.warning('Placement %s missing project channel', placement.id)
|
|
||||||
|
if not project or not channel:
|
||||||
|
log.warning('Placement %s missing project or channel', placement.id)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Generate post URL
|
cost_value = placement.cost_value
|
||||||
post_url = None
|
cost_type = placement.cost_type if placement.cost_type else domain.CostType.FIXED
|
||||||
if placement_post.post:
|
cost_before = placement.cost_before_bargain
|
||||||
post = placement_post.post
|
|
||||||
if post.channel.username:
|
subs_count = 0
|
||||||
post_url = f'https://t.me/{post.channel.username}/{post.message_id}'
|
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
|
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(
|
results.append(
|
||||||
dto.PlacementAnalyticsOutput(
|
dto.PlacementAnalyticsOutput(
|
||||||
id=placement_post.id,
|
id=placement.id,
|
||||||
project_id=placement.project_id,
|
project_id=project.id,
|
||||||
project_channel_title=placement.project.channel.title,
|
project_title=getattr(project, 'title', None) or getattr(project.channel, 'title', '')
|
||||||
placement_channel_id=placement.channel_id,
|
if project and project.channel
|
||||||
placement_channel_title=placement.channel.title,
|
else '',
|
||||||
|
channel_id=channel.id,
|
||||||
|
channel_title=channel.title,
|
||||||
creative_id=placement.creative_id,
|
creative_id=placement.creative_id,
|
||||||
creative_name=placement.creative.name,
|
creative_name=creative.name if creative else None,
|
||||||
placement_date=_get_placement_date(placement_post),
|
cost=cost_value,
|
||||||
cost=cost,
|
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,
|
subscriptions_count=subs_count,
|
||||||
unsubscriptions_count=unsubs_count,
|
|
||||||
views_count=views_count,
|
views_count=views_count,
|
||||||
cpf=cpf,
|
cpf=cpf_value,
|
||||||
cpm=_calculate_cpm(cost, views_count),
|
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_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,
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user