feat: add projects analytics endpoint
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -60,8 +60,26 @@ class WorkspaceMemberOutput(pydantic.BaseModel):
|
||||
permissions_map.setdefault(permission.permission, [])
|
||||
|
||||
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(
|
||||
WorkspacePermissionScopeOutput(type=scope.scope_type, id=scope.scope_id)
|
||||
WorkspacePermissionScopeOutput(type=scope_type, id=scope_id)
|
||||
)
|
||||
|
||||
return cls(
|
||||
|
||||
@@ -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