316 lines
13 KiB
Python
316 lines
13 KiB
Python
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)
|