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

@@ -495,7 +495,14 @@ class Postgres(DatabaseBase, Database):
return ( return (
await query.prefetch_related( 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') .order_by('-wanted_placement_date')
.all() .all()

View File

@@ -93,3 +93,27 @@ async def get_overview_analytics(
project_id=project_id, project_id=project_id,
) )
return await deps.get_usecase().get_overview_analytics(input) 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)

View File

@@ -63,6 +63,12 @@ __all__ = (
'GetOverviewAnalyticsOutput', 'GetOverviewAnalyticsOutput',
'GetSpendingAnalyticsInput', 'GetSpendingAnalyticsInput',
'GetSpendingAnalyticsOutput', 'GetSpendingAnalyticsOutput',
'DateGroupingType',
'ProjectMetrics',
'ProjectMetricsData',
'ProjectAnalyticsPeriod',
'GetProjectsAnalyticsInput',
'GetProjectsAnalyticsOutput',
'WorkspaceMembershipOutput', 'WorkspaceMembershipOutput',
'GetWorkspacesOutput', 'GetWorkspacesOutput',
'CreateWorkspaceInput', 'CreateWorkspaceInput',
@@ -87,6 +93,7 @@ from .analytics import (
ChannelAnalyticsOutput, ChannelAnalyticsOutput,
CreativeAnalyticsOutput, CreativeAnalyticsOutput,
DateGrouping, DateGrouping,
DateGroupingType,
GetChannelAnalyticsInput, GetChannelAnalyticsInput,
GetChannelAnalyticsOutput, GetChannelAnalyticsOutput,
GetCreativesAnalyticsInput, GetCreativesAnalyticsInput,
@@ -94,6 +101,8 @@ from .analytics import (
GetPlacementsAnalyticsInput, GetPlacementsAnalyticsInput,
GetPlacementsAnalyticsOutput, GetPlacementsAnalyticsOutput,
GetOverviewAnalyticsInput, GetOverviewAnalyticsInput,
GetProjectsAnalyticsInput,
GetProjectsAnalyticsOutput,
GetSpendingAnalyticsInput, GetSpendingAnalyticsInput,
GetSpendingAnalyticsOutput, GetSpendingAnalyticsOutput,
GetOverviewAnalyticsOutput, GetOverviewAnalyticsOutput,
@@ -102,6 +111,9 @@ from .analytics import (
OverviewDailyPoint, OverviewDailyPoint,
OverviewChannelPerformance, OverviewChannelPerformance,
OverviewProjectSpending, OverviewProjectSpending,
ProjectAnalyticsPeriod,
ProjectMetrics,
ProjectMetricsData,
SpendingDataPoint, SpendingDataPoint,
) )
from .channel import ( from .channel import (

View File

@@ -13,6 +13,27 @@ class DateGrouping(StrEnum):
YEAR = 'year' 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): class PlacementAnalyticsOutput(pydantic.BaseModel):
id: uuid.UUID id: uuid.UUID
project_id: uuid.UUID project_id: uuid.UUID
@@ -157,3 +178,40 @@ class GetOverviewAnalyticsOutput(pydantic.BaseModel):
top_channels_by_cpf: list[OverviewChannelPerformance] top_channels_by_cpf: list[OverviewChannelPerformance]
worst_channels_by_cpf: list[OverviewChannelPerformance] worst_channels_by_cpf: list[OverviewChannelPerformance]
project_spending: list[OverviewProjectSpending] 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

View File

@@ -60,8 +60,26 @@ class WorkspaceMemberOutput(pydantic.BaseModel):
permissions_map.setdefault(permission.permission, []) permissions_map.setdefault(permission.permission, [])
for scope in getattr(member, 'permission_scopes', []) or []: for scope in getattr(member, 'permission_scopes', []) or []:
# Определяем тип и 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( permissions_map.setdefault(scope.permission, []).append(
WorkspacePermissionScopeOutput(type=scope.scope_type, id=scope.scope_id) WorkspacePermissionScopeOutput(type=scope_type, id=scope_id)
) )
return cls( return cls(

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_creatives_analytics import get_creatives_analytics
from .analytics.get_overview_analytics import get_overview_analytics from .analytics.get_overview_analytics import get_overview_analytics
from .analytics.get_placements_analytics import get_placements_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 .analytics.get_spending_analytics import get_spending_analytics
from .auth.create_telegram_login_token import create_telegram_login_token from .auth.create_telegram_login_token import create_telegram_login_token
from .auth.get_jwt_by_telegram_id import get_jwt_by_telegram_id 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_placements_analytics = get_placements_analytics
get_creatives_analytics = get_creatives_analytics get_creatives_analytics = get_creatives_analytics
get_channel_analytics = get_channel_analytics get_channel_analytics = get_channel_analytics
get_projects_analytics = get_projects_analytics
get_spending_analytics = get_spending_analytics get_spending_analytics = get_spending_analytics
get_overview_analytics = get_overview_analytics get_overview_analytics = get_overview_analytics
get_workspaces = get_workspaces 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)