feat: add projects analytics endpoint
This commit is contained in:
@@ -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
|
||||
|
||||
299
src/usecase/analytics/get_projects_analytics.py
Normal file
299
src/usecase/analytics/get_projects_analytics.py
Normal file
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user