Merge pull request #2 from TelegramExchange/feat/projects-analytics

feat: add projects analytics endpoint
This commit is contained in:
Artem
2026-01-09 11:21:37 +03:00
committed by GitHub
7 changed files with 424 additions and 4 deletions

View File

@@ -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
@@ -450,6 +451,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

View 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)