feat: update analytics
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user