From a60daddcf154c41ce99d453e76c3a138a56af7e9 Mon Sep 17 00:00:00 2001 From: Artem Tsyrulnikov Date: Fri, 21 Nov 2025 00:54:11 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B0=D0=BD=D0=B0=D0=BB=D0=B8=D1=82?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- migration/versions/f05f85d10837_init.py | 11 +- src/adapter/postgres.py | 8 +- src/controller/http/__init__.py | 2 + src/controller/http/analytics.py | 65 ++++++++++ src/dto/__init__.py | 30 ++++- src/dto/analytics.py | 105 ++++++++++++++++ src/main.py | 8 +- src/usecase/__init__.py | 19 +-- src/usecase/analytics/__init__.py | 11 ++ .../analytics/get_creatives_analytics.py | 78 ++++++++++++ .../get_external_channels_analytics.py | 78 ++++++++++++ .../analytics/get_placements_analytics.py | 56 +++++++++ .../analytics/get_spending_analytics.py | 114 ++++++++++++++++++ src/usecase/telegram_login.py | 2 +- 14 files changed, 566 insertions(+), 21 deletions(-) create mode 100644 src/controller/http/analytics.py create mode 100644 src/dto/analytics.py create mode 100644 src/usecase/analytics/__init__.py create mode 100644 src/usecase/analytics/get_creatives_analytics.py create mode 100644 src/usecase/analytics/get_external_channels_analytics.py create mode 100644 src/usecase/analytics/get_placements_analytics.py create mode 100644 src/usecase/analytics/get_spending_analytics.py diff --git a/migration/versions/f05f85d10837_init.py b/migration/versions/f05f85d10837_init.py index a47ff33..efb5a42 100644 --- a/migration/versions/f05f85d10837_init.py +++ b/migration/versions/f05f85d10837_init.py @@ -5,17 +5,16 @@ Revises: Create Date: 2025-11-13 01:06:51.623272 """ -from typing import Sequence, Union +from collections.abc import Sequence -from alembic import op import sqlalchemy as sa - +from alembic import op # revision identifiers, used by Alembic. revision: str = 'f05f85d10837' -down_revision: Union[str, None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None def upgrade() -> None: diff --git a/src/adapter/postgres.py b/src/adapter/postgres.py index 7044766..2e85ea5 100644 --- a/src/adapter/postgres.py +++ b/src/adapter/postgres.py @@ -132,6 +132,11 @@ class Postgres(DatabaseBase): result = await self.session.execute(q) return list(result.scalars().all()) + async def get_user_external_channels(self, user_id: uuid.UUID) -> list[domain.ExternalChannel]: + q = select(domain.ExternalChannel).where(domain.ExternalChannel.user_id == user_id) + result = await self.session.execute(q) + return list(result.scalars().all()) + async def add_external_channel_to_targets( self, external_channel_id: uuid.UUID, target_channel_ids: list[uuid.UUID] ) -> None: @@ -204,7 +209,7 @@ class Postgres(DatabaseBase): return creative async def get_user_creatives( - self, user_id: uuid.UUID, *, target_channel_id: uuid.UUID | None = None, include_archived: bool = False + self, user_id: uuid.UUID, target_channel_id: uuid.UUID | None = None, include_archived: bool = False ) -> list[domain.Creative]: q = ( select(domain.Creative) @@ -261,7 +266,6 @@ class Postgres(DatabaseBase): async def get_user_placements( self, user_id: uuid.UUID, - *, target_channel_id: uuid.UUID | None = None, external_channel_id: uuid.UUID | None = None, creative_id: uuid.UUID | None = None, diff --git a/src/controller/http/__init__.py b/src/controller/http/__init__.py index 7b15d47..d611ab5 100644 --- a/src/controller/http/__init__.py +++ b/src/controller/http/__init__.py @@ -1,5 +1,6 @@ from fastapi import APIRouter +from src.controller.http.analytics import analytics_router from src.controller.http.auth import auth_router from src.controller.http.creatives import creatives_router from src.controller.http.external_channels import external_channels_router @@ -17,5 +18,6 @@ api_v1_router.include_router(external_channels_router) api_v1_router.include_router(creatives_router) api_v1_router.include_router(placements_router) api_v1_router.include_router(views_router) +api_v1_router.include_router(analytics_router) api_router.include_router(api_v1_router) diff --git a/src/controller/http/analytics.py b/src/controller/http/analytics.py new file mode 100644 index 0000000..1b113de --- /dev/null +++ b/src/controller/http/analytics.py @@ -0,0 +1,65 @@ +import datetime +import uuid +from typing import Annotated + +from fastapi import Depends +from fastapi.routing import APIRouter + +from src import dependencies, dto +from src.adapter.jwt import JWTPayload + +analytics_router = APIRouter(prefix='/analytics', tags=['analytics']) + + +@analytics_router.get('/placements') +async def get_placements_analytics( + current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)], + target_channel_id: uuid.UUID | None = None, +) -> dto.GetPlacementsAnalyticsOutput: + input = dto.GetPlacementsAnalyticsInput( + user_id=current_user.user_id, + target_channel_id=target_channel_id, + ) + return await dependencies.get_usecase().get_placements_analytics(input) + + +@analytics_router.get('/creatives') +async def get_creatives_analytics( + current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)], + target_channel_id: uuid.UUID | None = None, +) -> dto.GetCreativesAnalyticsOutput: + input = dto.GetCreativesAnalyticsInput( + user_id=current_user.user_id, + target_channel_id=target_channel_id, + ) + return await dependencies.get_usecase().get_creatives_analytics(input) + + +@analytics_router.get('/external-channels') +async def get_external_channels_analytics( + current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)], + target_channel_id: uuid.UUID | None = None, +) -> dto.GetExternalChannelsAnalyticsOutput: + input = dto.GetExternalChannelsAnalyticsInput( + user_id=current_user.user_id, + target_channel_id=target_channel_id, + ) + return await dependencies.get_usecase().get_external_channels_analytics(input) + + +@analytics_router.get('/spending') +async def get_spending_analytics( + current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)], + target_channel_id: uuid.UUID | None = None, + date_from: datetime.datetime | None = None, + date_to: datetime.datetime | None = None, + grouping: dto.DateGrouping = dto.DateGrouping.DAY, +) -> dto.GetSpendingAnalyticsOutput: + input = dto.GetSpendingAnalyticsInput( + user_id=current_user.user_id, + target_channel_id=target_channel_id, + date_from=date_from, + date_to=date_to, + grouping=grouping, + ) + return await dependencies.get_usecase().get_spending_analytics(input) diff --git a/src/dto/__init__.py b/src/dto/__init__.py index eeefe90..f557a70 100644 --- a/src/dto/__init__.py +++ b/src/dto/__init__.py @@ -38,8 +38,36 @@ __all__ = ( 'FetchViewsManuallyOutput', 'UpdateViewsManuallyInput', 'UserOutput', + 'DateGrouping', + 'PlacementAnalyticsOutput', + 'CreativeAnalyticsOutput', + 'ExternalChannelAnalyticsOutput', + 'GetPlacementsAnalyticsInput', + 'GetPlacementsAnalyticsOutput', + 'GetCreativesAnalyticsInput', + 'GetCreativesAnalyticsOutput', + 'GetExternalChannelsAnalyticsInput', + 'GetExternalChannelsAnalyticsOutput', + 'SpendingDataPoint', + 'GetSpendingAnalyticsInput', + 'GetSpendingAnalyticsOutput', ) +from .analytics import ( + CreativeAnalyticsOutput, + DateGrouping, + ExternalChannelAnalyticsOutput, + GetCreativesAnalyticsInput, + GetCreativesAnalyticsOutput, + GetExternalChannelsAnalyticsInput, + GetExternalChannelsAnalyticsOutput, + GetPlacementsAnalyticsInput, + GetPlacementsAnalyticsOutput, + GetSpendingAnalyticsInput, + GetSpendingAnalyticsOutput, + PlacementAnalyticsOutput, + SpendingDataPoint, +) from .creative import ( CreateCreativeInput, CreativeOutput, @@ -78,8 +106,8 @@ from .target_channel import ( UpdateTargetChanPermissionsInput, ) from .telegram_login import TelegramLoginInput -from .validate_login_token import ValidateLoginTokenInput, ValidateLoginTokenOutput from .user import UserOutput +from .validate_login_token import ValidateLoginTokenInput, ValidateLoginTokenOutput from .views import ( FetchViewsInputManually, FetchViewsManuallyOutput, diff --git a/src/dto/analytics.py b/src/dto/analytics.py new file mode 100644 index 0000000..57dbbad --- /dev/null +++ b/src/dto/analytics.py @@ -0,0 +1,105 @@ +import datetime +import uuid +from enum import StrEnum + +import pydantic + + +class DateGrouping(StrEnum): + DAY = 'day' + WEEK = 'week' + MONTH = 'month' + QUARTER = 'quarter' + YEAR = 'year' + + +class PlacementAnalyticsOutput(pydantic.BaseModel): + id: uuid.UUID + target_channel_id: uuid.UUID + target_channel_title: str + external_channel_id: uuid.UUID + external_channel_title: str + creative_id: uuid.UUID + creative_name: str + placement_date: datetime.datetime + cost: float | None + subscriptions_count: int + views_count: int | None + cpf: float | None + cpm: float | None + + +class CreativeAnalyticsOutput(pydantic.BaseModel): + id: uuid.UUID + name: str + placements_count: int + total_cost: float + total_subscriptions: int + total_views: int + avg_cpf: float | None + avg_cpm: float | None + + +class ExternalChannelAnalyticsOutput(pydantic.BaseModel): + id: uuid.UUID + title: str + username: str | None + placements_count: int + total_cost: float + total_subscriptions: int + total_views: int + avg_cpf: float | None + avg_cpm: float | None + + +class GetPlacementsAnalyticsInput(pydantic.BaseModel): + user_id: uuid.UUID + target_channel_id: uuid.UUID | None = None + + +class GetPlacementsAnalyticsOutput(pydantic.BaseModel): + placements: list[PlacementAnalyticsOutput] + + +class GetCreativesAnalyticsInput(pydantic.BaseModel): + user_id: uuid.UUID + target_channel_id: uuid.UUID | None = None + + +class GetCreativesAnalyticsOutput(pydantic.BaseModel): + creatives: list[CreativeAnalyticsOutput] + + +class GetExternalChannelsAnalyticsInput(pydantic.BaseModel): + user_id: uuid.UUID + target_channel_id: uuid.UUID | None = None + + +class GetExternalChannelsAnalyticsOutput(pydantic.BaseModel): + external_channels: list[ExternalChannelAnalyticsOutput] + + +class SpendingDataPoint(pydantic.BaseModel): + period: str + cost: float + subscriptions: int + views: int + cpf: float | None + cpm: float | None + + +class GetSpendingAnalyticsInput(pydantic.BaseModel): + user_id: uuid.UUID + target_channel_id: uuid.UUID | None = None + date_from: datetime.datetime | None = None + date_to: datetime.datetime | None = None + grouping: DateGrouping = DateGrouping.DAY + + +class GetSpendingAnalyticsOutput(pydantic.BaseModel): + total_cost: float + total_subscriptions: int + total_views: int + avg_cpf: float | None + avg_cpm: float | None + chart_data: list[SpendingDataPoint] diff --git a/src/main.py b/src/main.py index ac0c72a..0e5bd82 100644 --- a/src/main.py +++ b/src/main.py @@ -53,12 +53,12 @@ app = FastAPI(lifespan=lifespan, version=settings.logger.APP_VERSION) app.add_middleware( CORSMiddleware, allow_origins=[ - "https://app.tgexchange.ru", - "http://app.tgexchange.ru", + 'https://app.tgexchange.ru', + 'http://app.tgexchange.ru', ], allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=['*'], + allow_headers=['*'], ) app.include_router(api_router) diff --git a/src/usecase/__init__.py b/src/usecase/__init__.py index 950c05b..8fdc954 100644 --- a/src/usecase/__init__.py +++ b/src/usecase/__init__.py @@ -5,17 +5,21 @@ from uuid import UUID from src import domain +from .analytics.get_creatives_analytics import get_creatives_analytics +from .analytics.get_external_channels_analytics import get_external_channels_analytics +from .analytics.get_placements_analytics import get_placements_analytics +from .analytics.get_spending_analytics import get_spending_analytics from .creative.create_creative import create_creative from .creative.delete_creative import delete_creative from .creative.get_creative import get_creative from .creative.get_creatives import get_creatives from .creative.update_creative import update_creative -from .get_me import get_me from .external_channel.create_external_channel import create_external_channel from .external_channel.delete_external_channel import delete_external_channel from .external_channel.get_external_channels import get_external_channels from .external_channel.update_external_channel import update_external_channel from .external_channel.update_external_channel_links import update_external_channel_links +from .get_me import get_me from .placement.create_placement import create_placement from .placement.delete_placement import delete_placement from .placement.get_placement import get_placement @@ -70,6 +74,8 @@ class Database(typing.Protocol): async def get_external_channels_for_target(self, target_channel_id: UUID) -> list[domain.ExternalChannel]: ... + async def get_user_external_channels(self, user_id: UUID) -> list[domain.ExternalChannel]: ... + async def add_external_channel_to_targets( self, external_channel_id: UUID, target_channel_ids: list[UUID] ) -> None: ... @@ -87,11 +93,7 @@ class Database(typing.Protocol): async def update_creative(self, creative: domain.Creative) -> domain.Creative: ... async def get_user_creatives( - self, - user_id: UUID, - *, - target_channel_id: UUID | None = None, - include_archived: bool = False, + self, user_id: UUID, target_channel_id: UUID | None = None, include_archived: bool = False ) -> list[domain.Creative]: ... async def delete_creative(self, creative_id: UUID) -> None: ... @@ -105,7 +107,6 @@ class Database(typing.Protocol): async def get_user_placements( self, user_id: UUID, - *, target_channel_id: UUID | None = None, external_channel_id: UUID | None = None, creative_id: UUID | None = None, @@ -198,3 +199,7 @@ class Usecase: run_views_worker_cycle = run_views_worker_cycle get_views_history = get_views_history update_views_manually = update_views_manually + get_placements_analytics = get_placements_analytics + get_creatives_analytics = get_creatives_analytics + get_external_channels_analytics = get_external_channels_analytics + get_spending_analytics = get_spending_analytics diff --git a/src/usecase/analytics/__init__.py b/src/usecase/analytics/__init__.py new file mode 100644 index 0000000..d795c26 --- /dev/null +++ b/src/usecase/analytics/__init__.py @@ -0,0 +1,11 @@ +from .get_creatives_analytics import get_creatives_analytics +from .get_external_channels_analytics import get_external_channels_analytics +from .get_placements_analytics import get_placements_analytics +from .get_spending_analytics import get_spending_analytics + +__all__ = ( + 'get_placements_analytics', + 'get_creatives_analytics', + 'get_external_channels_analytics', + 'get_spending_analytics', +) diff --git a/src/usecase/analytics/get_creatives_analytics.py b/src/usecase/analytics/get_creatives_analytics.py new file mode 100644 index 0000000..10d1964 --- /dev/null +++ b/src/usecase/analytics/get_creatives_analytics.py @@ -0,0 +1,78 @@ +import logging +from dataclasses import dataclass +from typing import TYPE_CHECKING +from uuid import UUID + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def get_creatives_analytics( + self: 'Usecase', input: dto.GetCreativesAnalyticsInput +) -> dto.GetCreativesAnalyticsOutput: + async with self.database.transaction(): + if input.target_channel_id: + target_channel = await self.database.get_target_channel(input.user_id, input.target_channel_id) + if not target_channel: + raise domain.TargetChannelNotFound(input.target_channel_id) + + creatives = await self.database.get_user_creatives( + input.user_id, input.target_channel_id, include_archived=False + ) + + placements = await self.database.get_user_placements( + input.user_id, input.target_channel_id, include_archived=False + ) + + @dataclass + class CreativeStats: + total_cost: float = 0.0 + total_subscriptions: int = 0 + total_views: int = 0 + placements_count: int = 0 + + creative_stats: dict[UUID, CreativeStats] = {cr.id: CreativeStats() for cr in creatives} + + for p in placements: + stats = creative_stats.get(p.creative_id) + if stats is None: + continue + + stats.total_cost += p.cost if p.cost is not None else 0 + stats.total_subscriptions += p.subscriptions_count + stats.total_views += p.views_count if p.views_count is not None else 0 + stats.placements_count += 1 + + result = [] + for cr in creatives: + stats = creative_stats[cr.id] + + avg_cpf = ( + (stats.total_cost / stats.total_subscriptions) + if stats.total_subscriptions > 0 and stats.total_cost > 0 + else None + ) + avg_cpm = ( + (stats.total_cost / stats.total_views * 1000) + if stats.total_views > 0 and stats.total_cost > 0 + else None + ) + + result.append( + dto.CreativeAnalyticsOutput( + id=cr.id, + name=cr.name, + placements_count=stats.placements_count, + total_cost=stats.total_cost, + total_subscriptions=stats.total_subscriptions, + total_views=stats.total_views, + avg_cpf=avg_cpf, + avg_cpm=avg_cpm, + ) + ) + + return dto.GetCreativesAnalyticsOutput(creatives=result) diff --git a/src/usecase/analytics/get_external_channels_analytics.py b/src/usecase/analytics/get_external_channels_analytics.py new file mode 100644 index 0000000..362978a --- /dev/null +++ b/src/usecase/analytics/get_external_channels_analytics.py @@ -0,0 +1,78 @@ +import logging +from dataclasses import dataclass +from typing import TYPE_CHECKING +from uuid import UUID + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def get_external_channels_analytics( + self: 'Usecase', input: dto.GetExternalChannelsAnalyticsInput +) -> dto.GetExternalChannelsAnalyticsOutput: + async with self.database.transaction(): + if input.target_channel_id: + target_channel = await self.database.get_target_channel(input.user_id, input.target_channel_id) + if not target_channel: + raise domain.TargetChannelNotFound(input.target_channel_id) + external_channels = await self.database.get_external_channels_for_target(input.target_channel_id) + else: + external_channels = await self.database.get_user_external_channels(input.user_id) + + placements = await self.database.get_user_placements( + input.user_id, input.target_channel_id, include_archived=False + ) + + @dataclass + class ChannelStats: + total_cost: float = 0.0 + total_subscriptions: int = 0 + total_views: int = 0 + placements_count: int = 0 + + channel_stats: dict[UUID, ChannelStats] = {ch.id: ChannelStats() for ch in external_channels} + + for p in placements: + stats = channel_stats.get(p.external_channel_id) + if stats is None: + continue + + stats.total_cost += p.cost if p.cost is not None else 0 + stats.total_subscriptions += p.subscriptions_count + stats.total_views += p.views_count if p.views_count is not None else 0 + stats.placements_count += 1 + + result = [] + for ch in external_channels: + stats = channel_stats[ch.id] + + avg_cpf = ( + (stats.total_cost / stats.total_subscriptions) + if stats.total_subscriptions > 0 and stats.total_cost > 0 + else None + ) + avg_cpm = ( + (stats.total_cost / stats.total_views * 1000) + if stats.total_views > 0 and stats.total_cost > 0 + else None + ) + + result.append( + dto.ExternalChannelAnalyticsOutput( + id=ch.id, + title=ch.title, + username=ch.username, + placements_count=stats.placements_count, + total_cost=stats.total_cost, + total_subscriptions=stats.total_subscriptions, + total_views=stats.total_views, + avg_cpf=avg_cpf, + avg_cpm=avg_cpm, + ) + ) + + return dto.GetExternalChannelsAnalyticsOutput(external_channels=result) diff --git a/src/usecase/analytics/get_placements_analytics.py b/src/usecase/analytics/get_placements_analytics.py new file mode 100644 index 0000000..315070e --- /dev/null +++ b/src/usecase/analytics/get_placements_analytics.py @@ -0,0 +1,56 @@ +import logging +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +def _calculate_cpf(cost: float | None, subscriptions: int) -> float | None: + if cost is None or subscriptions == 0: + return None + return cost / subscriptions + + +def _calculate_cpm(cost: float | None, views: int | None) -> float | None: + if cost is None or views is None or views == 0: + return None + return (cost / views) * 1000 + + +async def get_placements_analytics( + self: 'Usecase', input: dto.GetPlacementsAnalyticsInput +) -> dto.GetPlacementsAnalyticsOutput: + async with self.database.transaction(): + if input.target_channel_id: + target_channel = await self.database.get_target_channel(input.user_id, input.target_channel_id) + if not target_channel: + raise domain.TargetChannelNotFound(input.target_channel_id) + + placements = await self.database.get_user_placements( + input.user_id, input.target_channel_id, include_archived=False + ) + + result = [ + dto.PlacementAnalyticsOutput( + id=p.id, + target_channel_id=p.target_channel_id, + target_channel_title=p.target_channel.title, + external_channel_id=p.external_channel_id, + external_channel_title=p.external_channel.title, + creative_id=p.creative_id, + creative_name=p.creative.name, + placement_date=p.placement_date, + cost=p.cost, + subscriptions_count=p.subscriptions_count, + views_count=p.views_count, + cpf=_calculate_cpf(p.cost, p.subscriptions_count), + cpm=_calculate_cpm(p.cost, p.views_count), + ) + for p in placements + ] + + return dto.GetPlacementsAnalyticsOutput(placements=result) diff --git a/src/usecase/analytics/get_spending_analytics.py b/src/usecase/analytics/get_spending_analytics.py new file mode 100644 index 0000000..68c3a00 --- /dev/null +++ b/src/usecase/analytics/get_spending_analytics.py @@ -0,0 +1,114 @@ +import datetime +import logging +from collections import defaultdict +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +def _format_period(dt: 'datetime.datetime', grouping: dto.DateGrouping) -> str: + match grouping: + case dto.DateGrouping.DAY: + return str(dt.strftime('%Y-%m-%d')) + case dto.DateGrouping.WEEK: + # ISO week + return str(dt.strftime('%Y-W%W')) + case dto.DateGrouping.MONTH: + return str(dt.strftime('%Y-%m')) + case dto.DateGrouping.QUARTER: + quarter = (dt.month - 1) // 3 + 1 + return f'{dt.year}-Q{quarter}' + case dto.DateGrouping.YEAR: + return str(dt.year) + + raise ValueError('Invalid date grouping') + + +async def get_spending_analytics( + self: 'Usecase', input: dto.GetSpendingAnalyticsInput +) -> dto.GetSpendingAnalyticsOutput: + async with self.database.transaction(): + if input.target_channel_id: + target_channel = await self.database.get_target_channel(input.user_id, input.target_channel_id) + if not target_channel: + raise domain.TargetChannelNotFound(input.target_channel_id) + + placements = await self.database.get_user_placements( + input.user_id, input.target_channel_id, include_archived=False + ) + + filtered = [ + p + for p in placements + if (not input.date_from or p.placement_date >= input.date_from) + and (not input.date_to or p.placement_date <= input.date_to) + ] + + total_cost = 0.0 + total_subs = 0 + total_views = 0 + + @dataclass + class PeriodData: + cost: float = 0.0 + subscriptions: int = 0 + views: int = 0 + + # Группировка по периодам + period_data: dict[str, PeriodData] = defaultdict(PeriodData) + + for p in filtered: + period = _format_period(p.placement_date, input.grouping) + pd = period_data[period] + + if p.cost is not None: + total_cost += p.cost + pd.cost += p.cost + + total_subs += p.subscriptions_count + pd.subscriptions += p.subscriptions_count + + if p.views_count is not None: + total_views += p.views_count + pd.views += p.views_count + + avg_cpf = total_cost / total_subs if total_subs > 0 and total_cost > 0 else None + avg_cpm = (total_cost / total_views * 1000) if total_views > 0 and total_cost > 0 else None + + # Данные для графика + chart_data = [] + for period in sorted(period_data.keys()): + d = period_data[period] + + cost = d.cost + subs = d.subscriptions + views = d.views + + cpf = cost / subs if subs > 0 and cost > 0 else None + cpm = (cost / views * 1000) if views > 0 and cost > 0 else None + + chart_data.append( + dto.SpendingDataPoint( + period=period, + cost=cost, + subscriptions=subs, + views=views, + cpf=cpf, + cpm=cpm, + ) + ) + + return dto.GetSpendingAnalyticsOutput( + total_cost=total_cost, + total_subscriptions=total_subs, + total_views=total_views, + avg_cpf=avg_cpf, + avg_cpm=avg_cpm, + chart_data=chart_data, + ) diff --git a/src/usecase/telegram_login.py b/src/usecase/telegram_login.py index 057c4c6..f0ff586 100644 --- a/src/usecase/telegram_login.py +++ b/src/usecase/telegram_login.py @@ -25,7 +25,7 @@ async def telegram_login(self: 'Usecase', input: dto.TelegramLoginInput) -> None await self.database.create_login_token(user.id, token, expires_at) - login_url = f'{settings.app.URL}/auth/complete?token={token}' + login_url = f'{settings.app.URL}/api/v1/auth/complete?token={token}' username_display = f'@{user.username}' if user.username else 'пользователь' await self.telegram_bot.send_message_with_button(