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)