diff --git a/src/adapter/postgres.py b/src/adapter/postgres.py index 8d2b635..cc9f808 100644 --- a/src/adapter/postgres.py +++ b/src/adapter/postgres.py @@ -491,7 +491,14 @@ class Postgres(DatabaseBase, Database): return ( await query.prefetch_related( - 'project', 'project__channel', 'purchase_channel', 'purchase_channel__channel', 'creative', 'post', 'post__channel' + 'project', + 'project__channel', + 'purchase_channel', + 'purchase_channel__channel', + 'purchase_channel__purchase', + 'creative', + 'post', + 'post__channel', ) .order_by('-wanted_placement_date') .all() diff --git a/src/controller/http_v1/analytics.py b/src/controller/http_v1/analytics.py index c039bec..3c30029 100644 --- a/src/controller/http_v1/analytics.py +++ b/src/controller/http_v1/analytics.py @@ -93,3 +93,27 @@ async def get_overview_analytics( project_id=project_id, ) return await deps.get_usecase().get_overview_analytics(input) + + +@analytics_router.get('/projects') +async def get_projects_analytics( + workspace_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], + project_ids: list[uuid.UUID] | None = None, + date_from: datetime.datetime | None = None, + date_to: datetime.datetime | None = None, + grouping: dto.DateGrouping = dto.DateGrouping.DAY, + date_grouping: dto.DateGroupingType = dto.DateGroupingType.PLACEMENT_DATE, + metrics: list[dto.ProjectMetrics] | None = None, +) -> dto.GetProjectsAnalyticsOutput: + input = dto.GetProjectsAnalyticsInput( + user_id=current_user.user_id, + workspace_id=workspace_id, + project_ids=project_ids, + date_from=date_from, + date_to=date_to, + grouping=grouping, + date_grouping=date_grouping, + metrics=metrics, + ) + return await deps.get_usecase().get_projects_analytics(input) diff --git a/src/dto/__init__.py b/src/dto/__init__.py index 045bd85..cb30921 100644 --- a/src/dto/__init__.py +++ b/src/dto/__init__.py @@ -62,6 +62,12 @@ __all__ = ( 'GetOverviewAnalyticsOutput', 'GetSpendingAnalyticsInput', 'GetSpendingAnalyticsOutput', + 'DateGroupingType', + 'ProjectMetrics', + 'ProjectMetricsData', + 'ProjectAnalyticsPeriod', + 'GetProjectsAnalyticsInput', + 'GetProjectsAnalyticsOutput', 'WorkspaceMembershipOutput', 'GetWorkspacesOutput', 'CreateWorkspaceInput', @@ -86,6 +92,7 @@ from .analytics import ( ChannelAnalyticsOutput, CreativeAnalyticsOutput, DateGrouping, + DateGroupingType, GetChannelAnalyticsInput, GetChannelAnalyticsOutput, GetCreativesAnalyticsInput, @@ -93,6 +100,8 @@ from .analytics import ( GetPlacementsAnalyticsInput, GetPlacementsAnalyticsOutput, GetOverviewAnalyticsInput, + GetProjectsAnalyticsInput, + GetProjectsAnalyticsOutput, GetSpendingAnalyticsInput, GetSpendingAnalyticsOutput, GetOverviewAnalyticsOutput, @@ -101,6 +110,9 @@ from .analytics import ( OverviewDailyPoint, OverviewChannelPerformance, OverviewProjectSpending, + ProjectAnalyticsPeriod, + ProjectMetrics, + ProjectMetricsData, SpendingDataPoint, ) from .channel import ( diff --git a/src/dto/analytics.py b/src/dto/analytics.py index 7f76263..907af9f 100644 --- a/src/dto/analytics.py +++ b/src/dto/analytics.py @@ -13,6 +13,27 @@ class DateGrouping(StrEnum): YEAR = 'year' +class DateGroupingType(StrEnum): + PURCHASE_DATE = 'purchase_date' + LINK_DATE = 'link_date' + PLACEMENT_DATE = 'placement_date' + + +class ProjectMetrics(StrEnum): + TOTAL_COST = 'total_cost' + PURCHASES_COUNT = 'purchases_count' + TOTAL_SUBSCRIPTIONS = 'total_subscriptions' + TOTAL_VIEWS = 'total_views' + AVG_CPF = 'avg_cpf' + AVG_CPM = 'avg_cpm' + AVG_POST_COST = 'avg_post_cost' + CLICKS_COUNT = 'clicks_count' + REACH_VOLUME = 'reach_volume' + TOTAL_DISCOUNTS = 'total_discounts' + AVG_DISCOUNT_PERCENT = 'avg_discount_percent' + AVG_CONVERSION = 'avg_conversion' + + class PlacementAnalyticsOutput(pydantic.BaseModel): id: uuid.UUID project_id: uuid.UUID @@ -157,3 +178,40 @@ class GetOverviewAnalyticsOutput(pydantic.BaseModel): top_channels_by_cpf: list[OverviewChannelPerformance] worst_channels_by_cpf: list[OverviewChannelPerformance] project_spending: list[OverviewProjectSpending] + + +class ProjectMetricsData(pydantic.BaseModel): + total_cost: float | None = None + purchases_count: int | None = None + total_subscriptions: int | None = None + total_views: int | None = None + avg_cpf: float | None = None + avg_cpm: float | None = None + avg_post_cost: float | None = None + clicks_count: int | None = None + reach_volume: int | None = None + total_discounts: float | None = None + avg_discount_percent: float | None = None + avg_conversion: float | None = None + + +class ProjectAnalyticsPeriod(pydantic.BaseModel): + period: str + period_label: str + metrics: ProjectMetricsData + + +class GetProjectsAnalyticsInput(pydantic.BaseModel): + user_id: uuid.UUID + workspace_id: uuid.UUID + project_ids: list[uuid.UUID] | None = None + date_from: datetime.datetime | None = None + date_to: datetime.datetime | None = None + grouping: DateGrouping = DateGrouping.DAY + date_grouping: DateGroupingType = DateGroupingType.PLACEMENT_DATE + metrics: list[ProjectMetrics] | None = None + + +class GetProjectsAnalyticsOutput(pydantic.BaseModel): + periods: list[ProjectAnalyticsPeriod] + totals: ProjectMetricsData diff --git a/src/dto/workspace.py b/src/dto/workspace.py index 5e621ae..030bd23 100644 --- a/src/dto/workspace.py +++ b/src/dto/workspace.py @@ -60,9 +60,27 @@ class WorkspaceMemberOutput(pydantic.BaseModel): permissions_map.setdefault(permission.permission, []) for scope in getattr(member, 'permission_scopes', []) or []: - permissions_map.setdefault(scope.permission, []).append( - WorkspacePermissionScopeOutput(type=scope.scope_type, id=scope.scope_id) - ) + # Определяем тип и ID scope на основе заполненных полей + scope_type = None + scope_id = None + + if scope.project_id: + scope_type = domain.PermissionScopeType.PROJECT + scope_id = scope.project_id + elif scope.creative_id: + scope_type = domain.PermissionScopeType.CREATIVE + scope_id = scope.creative_id + elif scope.placement_id: + scope_type = domain.PermissionScopeType.PLACEMENT + scope_id = scope.placement_id + elif scope.channel_id: + scope_type = domain.PermissionScopeType.CHANNEL + scope_id = scope.channel_id + + if scope_type and scope_id: + permissions_map.setdefault(scope.permission, []).append( + WorkspacePermissionScopeOutput(type=scope_type, id=scope_id) + ) return cls( id=member.id, diff --git a/src/usecase/__init__.py b/src/usecase/__init__.py index cb2f136..e170255 100644 --- a/src/usecase/__init__.py +++ b/src/usecase/__init__.py @@ -12,6 +12,7 @@ from .analytics.get_channel_analytics import get_channel_analytics from .analytics.get_creatives_analytics import get_creatives_analytics from .analytics.get_overview_analytics import get_overview_analytics from .analytics.get_placements_analytics import get_placements_analytics +from .analytics.get_projects_analytics import get_projects_analytics from .analytics.get_spending_analytics import get_spending_analytics from .auth.create_telegram_login_token import create_telegram_login_token from .auth.get_jwt_by_telegram_id import get_jwt_by_telegram_id @@ -446,6 +447,7 @@ class Usecase: get_placements_analytics = get_placements_analytics get_creatives_analytics = get_creatives_analytics get_channel_analytics = get_channel_analytics + get_projects_analytics = get_projects_analytics get_spending_analytics = get_spending_analytics get_overview_analytics = get_overview_analytics get_workspaces = get_workspaces diff --git a/src/usecase/analytics/get_projects_analytics.py b/src/usecase/analytics/get_projects_analytics.py new file mode 100644 index 0000000..98a545f --- /dev/null +++ b/src/usecase/analytics/get_projects_analytics.py @@ -0,0 +1,299 @@ +import datetime +from collections import defaultdict +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from fastapi import HTTPException, status + +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]}' + 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_grouping_date(placement: domain.Placement, date_grouping: dto.DateGroupingType) -> datetime.datetime: + match date_grouping: + case dto.DateGroupingType.PLACEMENT_DATE: + return placement.wanted_placement_date + case dto.DateGroupingType.PURCHASE_DATE: + if placement.purchase_channel and placement.purchase_channel.purchase: + return placement.purchase_channel.purchase.created_at + return placement.wanted_placement_date # Fallback + case dto.DateGroupingType.LINK_DATE: + if placement.purchase_channel: + return placement.purchase_channel.created_at + return placement.wanted_placement_date # Fallback + case _: + return placement.wanted_placement_date + + +@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, +) -> dto.ProjectMetricsData: + all_metrics = requested_metrics is None or len(requested_metrics) == 0 + + def should_include(metric: dto.ProjectMetrics) -> bool: + return all_metrics or 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 = 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 = 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): + 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 + 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_workspace_permission( + input.workspace_id, input.user_id, domain.PermissionKey.ANALYTICS_READ + ) + + allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ) + + # Фильтрация по 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_placements( + 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 in placements: + grouping_date = _get_grouping_date(placement, 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) + + # 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_ids = [p.id for p in filtered_placements] + subscriptions_counts = await self.database.count_subscriptions_by_placement_batch(placement_ids) + + # Группировка по периодам + period_data: dict[str, PeriodMetrics] = defaultdict(PeriodMetrics) + total_metrics = PeriodMetrics() + + for placement in filtered_placements: + grouping_date = _get_grouping_date(placement, input.date_grouping) + period = _format_period(grouping_date, input.grouping) + pd = period_data[period] + + # Обновляем метрики периода + pd.purchases_count += 1 + total_metrics.purchases_count += 1 + + cost = placement.cost or 0.0 + pd.total_cost += cost + total_metrics.total_cost += cost + + subs_count = subscriptions_counts.get(placement.id, 0) + pd.total_subscriptions += subs_count + pd.clicks_count += subs_count + total_metrics.total_subscriptions += subs_count + total_metrics.clicks_count += subs_count + + views_count = 0 + if placement.post and placement.post.id in views_map: + views_count = views_map[placement.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 + + # Расчет скидок + purchase_channel = placement.purchase_channel + if purchase_channel and purchase_channel.cost_before_bargain and purchase_channel.cost_before_bargain > cost: + discount = purchase_channel.cost_before_bargain - cost + discount_percent = (discount / purchase_channel.cost_before_bargain) * 100 if purchase_channel.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') + 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) + 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') + 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) + else: + period_dt = datetime.datetime.now() + + period_label = _format_period_label(period_dt, input.grouping) + metrics = _calculate_metrics(pd, input.metrics) + + periods.append( + dto.ProjectAnalyticsPeriod( + period=period_key, + period_label=period_label, + metrics=metrics, + ) + ) + + totals = _calculate_metrics(total_metrics, input.metrics) + + return dto.GetProjectsAnalyticsOutput(periods=periods, totals=totals) +