feat: add projects analytics endpoint
This commit is contained in:
@@ -491,7 +491,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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ __all__ = (
|
|||||||
'GetOverviewAnalyticsOutput',
|
'GetOverviewAnalyticsOutput',
|
||||||
'GetSpendingAnalyticsInput',
|
'GetSpendingAnalyticsInput',
|
||||||
'GetSpendingAnalyticsOutput',
|
'GetSpendingAnalyticsOutput',
|
||||||
|
'DateGroupingType',
|
||||||
|
'ProjectMetrics',
|
||||||
|
'ProjectMetricsData',
|
||||||
|
'ProjectAnalyticsPeriod',
|
||||||
|
'GetProjectsAnalyticsInput',
|
||||||
|
'GetProjectsAnalyticsOutput',
|
||||||
'WorkspaceMembershipOutput',
|
'WorkspaceMembershipOutput',
|
||||||
'GetWorkspacesOutput',
|
'GetWorkspacesOutput',
|
||||||
'CreateWorkspaceInput',
|
'CreateWorkspaceInput',
|
||||||
@@ -86,6 +92,7 @@ from .analytics import (
|
|||||||
ChannelAnalyticsOutput,
|
ChannelAnalyticsOutput,
|
||||||
CreativeAnalyticsOutput,
|
CreativeAnalyticsOutput,
|
||||||
DateGrouping,
|
DateGrouping,
|
||||||
|
DateGroupingType,
|
||||||
GetChannelAnalyticsInput,
|
GetChannelAnalyticsInput,
|
||||||
GetChannelAnalyticsOutput,
|
GetChannelAnalyticsOutput,
|
||||||
GetCreativesAnalyticsInput,
|
GetCreativesAnalyticsInput,
|
||||||
@@ -93,6 +100,8 @@ from .analytics import (
|
|||||||
GetPlacementsAnalyticsInput,
|
GetPlacementsAnalyticsInput,
|
||||||
GetPlacementsAnalyticsOutput,
|
GetPlacementsAnalyticsOutput,
|
||||||
GetOverviewAnalyticsInput,
|
GetOverviewAnalyticsInput,
|
||||||
|
GetProjectsAnalyticsInput,
|
||||||
|
GetProjectsAnalyticsOutput,
|
||||||
GetSpendingAnalyticsInput,
|
GetSpendingAnalyticsInput,
|
||||||
GetSpendingAnalyticsOutput,
|
GetSpendingAnalyticsOutput,
|
||||||
GetOverviewAnalyticsOutput,
|
GetOverviewAnalyticsOutput,
|
||||||
@@ -101,6 +110,9 @@ from .analytics import (
|
|||||||
OverviewDailyPoint,
|
OverviewDailyPoint,
|
||||||
OverviewChannelPerformance,
|
OverviewChannelPerformance,
|
||||||
OverviewProjectSpending,
|
OverviewProjectSpending,
|
||||||
|
ProjectAnalyticsPeriod,
|
||||||
|
ProjectMetrics,
|
||||||
|
ProjectMetricsData,
|
||||||
SpendingDataPoint,
|
SpendingDataPoint,
|
||||||
)
|
)
|
||||||
from .channel import (
|
from .channel import (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -60,9 +60,27 @@ 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 []:
|
||||||
permissions_map.setdefault(scope.permission, []).append(
|
# Определяем тип и ID scope на основе заполненных полей
|
||||||
WorkspacePermissionScopeOutput(type=scope.scope_type, id=scope.scope_id)
|
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(
|
return cls(
|
||||||
id=member.id,
|
id=member.id,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -446,6 +447,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
|
||||||
|
|||||||
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