Files
tgex-backend/src/usecase/analytics/get_projects_analytics.py
2026-01-28 12:34:00 +03:00

316 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import datetime
from collections import defaultdict
from dataclasses import dataclass
from typing import TYPE_CHECKING
from fastapi import HTTPException, status
from tortoise import timezone
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
def _format_period(dt: datetime.datetime, grouping: dto.DateGrouping) -> str:
match grouping:
case dto.DateGrouping.DAY:
return dt.strftime('%Y-%m-%d')
case dto.DateGrouping.WEEK:
# ISO week
year, week, _ = dt.isocalendar()
return f'{year}-W{week:02d}'
case dto.DateGrouping.MONTH:
return dt.strftime('%Y-%m')
case dto.DateGrouping.QUARTER:
quarter = (dt.month - 1) // 3 + 1
return f'{dt.year}-Q{quarter}'
case _:
raise ValueError('Invalid date grouping')
def _format_period_label(dt: datetime.datetime, grouping: dto.DateGrouping) -> str:
match grouping:
case dto.DateGrouping.DAY:
# "1 дек" или "1 дек 2024"
day = dt.day
month_names = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
month = month_names[dt.month - 1]
return f'{day} {month}'
case dto.DateGrouping.WEEK:
# "49 нед. 2024" или "1-7 дек"
year, week, _ = dt.isocalendar()
# Находим первый день недели (понедельник)
days_since_monday = dt.weekday()
week_start = dt - datetime.timedelta(days=days_since_monday)
week_end = week_start + datetime.timedelta(days=6)
month_names = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
if week_start.month == week_end.month:
return f'{week_start.day}-{week_end.day} {month_names[week_start.month - 1]}'
else:
return f'{week_start.day} {month_names[week_start.month - 1]}-{week_end.day} {month_names[week_end.month - 1]}' # noqa: E501
case dto.DateGrouping.MONTH:
# "дек 2024"
month_names = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
return f'{month_names[dt.month - 1]} {dt.year}'
case dto.DateGrouping.QUARTER:
# "Q4 2024"
quarter = (dt.month - 1) // 3 + 1
return f'Q{quarter} {dt.year}'
case _:
raise ValueError('Invalid date grouping')
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_grouping_date(placement_post: domain.PlacementPost, date_grouping: dto.DateGroupingType) -> datetime.datetime:
match date_grouping:
case dto.DateGroupingType.PLACEMENT_DATE:
return _get_placement_date(placement_post)
case dto.DateGroupingType.PURCHASE_DATE:
if placement_post.placement:
return placement_post.placement.created_at
return _get_placement_date(placement_post) # Fallback
case dto.DateGroupingType.LINK_DATE:
if placement_post.placement:
return placement_post.placement.created_at
return _get_placement_date(placement_post) # Fallback
case _:
return _get_placement_date(placement_post)
@dataclass
class PeriodMetrics:
total_cost: float = 0.0
purchases_count: int = 0
total_subscriptions: int = 0
total_views: int = 0
clicks_count: int = 0
reach_volume: int = 0
total_discounts: float = 0.0
discount_count: int = 0
discount_sum: float = 0.0
def _calculate_metrics(
period_metrics: PeriodMetrics,
requested_metrics: list[dto.ProjectMetrics] | None,
hide_subscriptions: bool = False,
) -> dto.ProjectMetricsData:
all_metrics = requested_metrics is None or len(requested_metrics) == 0
def should_include(metric: dto.ProjectMetrics) -> bool:
return all_metrics or (requested_metrics is not None and metric in requested_metrics)
metrics = dto.ProjectMetricsData()
if should_include(dto.ProjectMetrics.TOTAL_COST):
metrics.total_cost = period_metrics.total_cost
if should_include(dto.ProjectMetrics.PURCHASES_COUNT):
metrics.purchases_count = period_metrics.purchases_count
if should_include(dto.ProjectMetrics.TOTAL_SUBSCRIPTIONS):
metrics.total_subscriptions = 0 if hide_subscriptions else period_metrics.total_subscriptions
if should_include(dto.ProjectMetrics.TOTAL_VIEWS):
metrics.total_views = period_metrics.total_views
if should_include(dto.ProjectMetrics.CLICKS_COUNT):
metrics.clicks_count = 0 if hide_subscriptions else period_metrics.clicks_count
if should_include(dto.ProjectMetrics.REACH_VOLUME):
metrics.reach_volume = period_metrics.reach_volume
if should_include(dto.ProjectMetrics.TOTAL_DISCOUNTS):
metrics.total_discounts = period_metrics.total_discounts
# Средние значения
if should_include(dto.ProjectMetrics.AVG_CPF):
if hide_subscriptions:
metrics.avg_cpf = None
else:
metrics.avg_cpf = (
period_metrics.total_cost / period_metrics.total_subscriptions
if period_metrics.total_subscriptions > 0 and period_metrics.total_cost > 0
else None
)
if should_include(dto.ProjectMetrics.AVG_CPM):
metrics.avg_cpm = (
(period_metrics.total_cost / period_metrics.total_views) * 1000
if period_metrics.total_views > 0 and period_metrics.total_cost > 0
else None
)
if should_include(dto.ProjectMetrics.AVG_POST_COST):
metrics.avg_post_cost = (
period_metrics.total_cost / period_metrics.purchases_count
if period_metrics.purchases_count > 0 and period_metrics.total_cost > 0
else None
)
if should_include(dto.ProjectMetrics.AVG_DISCOUNT_PERCENT):
if period_metrics.discount_count > 0:
metrics.avg_discount_percent = period_metrics.discount_sum / period_metrics.discount_count
else:
metrics.avg_discount_percent = 0.0
if should_include(dto.ProjectMetrics.AVG_CONVERSION):
# Конверсия = подписки / просмотры * 100
if hide_subscriptions:
metrics.avg_conversion = None
else:
metrics.avg_conversion = (
(period_metrics.total_subscriptions / period_metrics.total_views) * 100
if period_metrics.total_views > 0 and period_metrics.total_subscriptions > 0
else 0.0
)
return metrics
async def get_projects_analytics(
self: 'Usecase', input: dto.GetProjectsAnalyticsInput
) -> dto.GetProjectsAnalyticsOutput:
if input.date_from and input.date_to and input.date_from > input.date_to:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'date_from must be before date_to')
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()
# Фильтрация по project_ids если указаны
if input.project_ids:
if allowed_project_ids is not None:
# Пересечение разрешенных и запрошенных
filtered_ids = [pid for pid in input.project_ids if pid in allowed_project_ids]
if not filtered_ids:
return dto.GetProjectsAnalyticsOutput(periods=[], totals=dto.ProjectMetricsData())
allowed_project_ids = set(filtered_ids)
else:
allowed_project_ids = set(input.project_ids)
placements = await self.database.get_workspace_placement_posts(
input.workspace_id,
project_id=None,
include_archived=False,
allowed_project_ids=allowed_project_ids,
date_from=input.date_from,
date_to=input.date_to,
)
# Фильтрация по датам на основе date_grouping
filtered_placements = []
for placement_post in placements:
grouping_date = _get_grouping_date(placement_post, input.date_grouping)
if input.date_from and grouping_date < input.date_from:
continue
if input.date_to and grouping_date > input.date_to:
continue
filtered_placements.append(placement_post)
# Batch fetch views data
post_ids = [p.post.id for p in filtered_placements if p.post]
views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {}
# Batch fetch subscriptions counts
placement_post_ids = [p.id for p in filtered_placements]
subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_post_ids)
# Группировка по периодам
period_data: dict[str, PeriodMetrics] = defaultdict(PeriodMetrics)
total_metrics = PeriodMetrics()
for placement_post in filtered_placements:
grouping_date = _get_grouping_date(placement_post, input.date_grouping)
period = _format_period(grouping_date, input.grouping)
pd = period_data[period]
# Обновляем метрики периода
pd.purchases_count += 1
total_metrics.purchases_count += 1
placement = placement_post.placement
cost = placement.cost_value if placement and placement.cost_value is not None else 0.0
pd.total_cost += cost
total_metrics.total_cost += cost
subs_count = subscriptions_counts.get(placement_post.id, 0)
pd.total_subscriptions += subs_count
pd.clicks_count += subs_count
total_metrics.total_subscriptions += subs_count
total_metrics.clicks_count += subs_count
if placement_post.post and placement_post.post.id in views_map:
views_count = views_map[placement_post.post.id][0]
pd.total_views += views_count
pd.reach_volume += views_count
total_metrics.total_views += views_count
total_metrics.reach_volume += views_count
# Расчет скидок
if placement and placement.cost_before_bargain and placement.cost_before_bargain > cost:
discount = placement.cost_before_bargain - cost
discount_percent = (
(discount / placement.cost_before_bargain) * 100 if placement.cost_before_bargain > 0 else 0.0
)
pd.total_discounts += discount
pd.discount_count += 1
pd.discount_sum += discount_percent
total_metrics.total_discounts += discount
total_metrics.discount_count += 1
total_metrics.discount_sum += discount_percent
# Формируем периоды с метриками
periods: list[dto.ProjectAnalyticsPeriod] = []
for period_key in sorted(period_data.keys()):
pd = period_data[period_key]
# Определяем дату для period_label (берем первую дату периода)
if input.grouping == dto.DateGrouping.DAY:
period_dt = datetime.datetime.strptime(period_key, '%Y-%m-%d').replace(tzinfo=datetime.UTC)
elif input.grouping == dto.DateGrouping.WEEK:
# ISO week format: YYYY-Www
year, week_str = period_key.split('-W')
week = int(week_str)
# Находим первый день недели (понедельник) для данной ISO недели
jan4 = datetime.datetime(int(year), 1, 4, tzinfo=datetime.UTC)
jan4_weekday = jan4.weekday() # 0=Monday, 6=Sunday
days_since_monday = (jan4_weekday + 1) % 7
jan4_monday = jan4 - datetime.timedelta(days=days_since_monday)
period_dt = jan4_monday + datetime.timedelta(weeks=week - 1)
elif input.grouping == dto.DateGrouping.MONTH:
period_dt = datetime.datetime.strptime(period_key, '%Y-%m').replace(tzinfo=datetime.UTC)
elif input.grouping == dto.DateGrouping.QUARTER:
year, quarter = period_key.split('-Q')
month = (int(quarter) - 1) * 3 + 1
period_dt = datetime.datetime(int(year), month, 1, tzinfo=datetime.UTC)
else:
period_dt = timezone.now()
period_label = _format_period_label(period_dt, input.grouping)
metrics = _calculate_metrics(pd, input.metrics, hide_subscriptions)
periods.append(
dto.ProjectAnalyticsPeriod(
period=period_key,
period_label=period_label,
metrics=metrics,
)
)
totals = _calculate_metrics(total_metrics, input.metrics, hide_subscriptions)
return dto.GetProjectsAnalyticsOutput(periods=periods, totals=totals)