diff --git a/src/adapter/postgres.py b/src/adapter/postgres.py index 0b8ba17..439e403 100644 --- a/src/adapter/postgres.py +++ b/src/adapter/postgres.py @@ -322,6 +322,14 @@ class Postgres(DatabaseBase): project.status = domain.ProjectStatus.ARCHIVED await project.save() + @staticmethod + async def unarchive_project(workspace_id: uuid.UUID, project_id: uuid.UUID) -> None: + project = await domain.Project.get_or_none(id=project_id, workspace_id=workspace_id) + if not project: + raise domain.ProjectNotFound() + project.status = domain.ProjectStatus.ACTIVE + await project.save() + @staticmethod async def delete_project(workspace_id: uuid.UUID, project_id: uuid.UUID) -> None: project = await domain.Project.get_or_none(id=project_id, workspace_id=workspace_id) @@ -443,6 +451,7 @@ class Postgres(DatabaseBase): allowed_project_ids: set[uuid.UUID] | None = None, date_from: datetime.datetime | None = None, date_to: datetime.datetime | None = None, + has_post: bool = False, ) -> list[domain.PlacementPost]: query = domain.PlacementPost.filter(placement__project__workspace_id=workspace_id) @@ -464,6 +473,9 @@ class Postgres(DatabaseBase): if date_to: query = query.filter(created_at__lte=date_to) + if has_post: + query = query.filter(post__isnull=False) + return ( await query.prefetch_related( 'placement', diff --git a/src/controller/http_v1/analytics.py b/src/controller/http_v1/analytics.py index 3c30029..99b7c02 100644 --- a/src/controller/http_v1/analytics.py +++ b/src/controller/http_v1/analytics.py @@ -17,11 +17,19 @@ async def get_placements_analytics( workspace_id: uuid.UUID, current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], project_id: uuid.UUID | None = None, + placement_channel_id: uuid.UUID | None = None, + creative_id: uuid.UUID | None = None, + date_from: datetime.datetime | None = None, + date_to: datetime.datetime | None = None, ) -> Page[dto.PlacementAnalyticsOutput]: input = dto.GetPlacementsAnalyticsInput( user_id=current_user.user_id, workspace_id=workspace_id, project_id=project_id, + placement_channel_id=placement_channel_id, + creative_id=creative_id, + date_from=date_from, + date_to=date_to, ) result = await deps.get_usecase().get_placements_analytics(input) return paginate(result) # type: ignore[no-any-return] diff --git a/src/controller/http_v1/projects.py b/src/controller/http_v1/projects.py index 7212b02..25c415b 100644 --- a/src/controller/http_v1/projects.py +++ b/src/controller/http_v1/projects.py @@ -75,6 +75,20 @@ async def archive_project( return await deps.get_usecase().archive_project(input=input) +@projects_router.post('/{project_id}/unarchive') +async def unarchive_project( + workspace_id: uuid.UUID, + project_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.ProjectOutput: + input = dto.ArchiveProjectInput( + workspace_id=workspace_id, + project_id=project_id, + user_id=current_user.user_id, + ) + return await deps.get_usecase().unarchive_project(input=input) + + @projects_router.delete('/{project_id}') async def delete_project( workspace_id: uuid.UUID, diff --git a/src/dto/analytics.py b/src/dto/analytics.py index 907af9f..8ab558f 100644 --- a/src/dto/analytics.py +++ b/src/dto/analytics.py @@ -48,6 +48,7 @@ class PlacementAnalyticsOutput(pydantic.BaseModel): views_count: int | None cpf: float | None cpm: float | None + post_url: str | None = None class CreativeAnalyticsOutput(pydantic.BaseModel): @@ -77,6 +78,10 @@ class GetPlacementsAnalyticsInput(pydantic.BaseModel): user_id: uuid.UUID workspace_id: uuid.UUID project_id: uuid.UUID | None = None + placement_channel_id: uuid.UUID | None = None + creative_id: uuid.UUID | None = None + date_from: datetime.datetime | None = None + date_to: datetime.datetime | None = None class GetPlacementsAnalyticsOutput(pydantic.BaseModel): @@ -128,6 +133,7 @@ class GetSpendingAnalyticsOutput(pydantic.BaseModel): avg_cpf: float | None avg_cpm: float | None chart_data: list[SpendingDataPoint] + placements_count: int = 0 class NumberWithDelta(pydantic.BaseModel): diff --git a/src/usecase/__init__.py b/src/usecase/__init__.py index 3965f83..845ca03 100644 --- a/src/usecase/__init__.py +++ b/src/usecase/__init__.py @@ -30,7 +30,7 @@ from .creative.get_creative import get_creative from .creative.get_creatives import get_creatives from .creative.update_creative import update_creative from .placement.fetch_placement_post_cycle import fetch_placement_post_cycle -from .project.archive_project import archive_project +from .project.archive_project import archive_project, unarchive_project from .project.delete_project import delete_project from .project.disconnect_project_by_tg_id import disconnect_project_by_tg_id from .project.get_project import get_project @@ -183,6 +183,7 @@ class Usecase: get_workspace_projects = get_workspace_projects get_project = get_project archive_project = archive_project + unarchive_project = unarchive_project delete_project = delete_project move_project_to_workspace = move_project_to_workspace disconnect_project_by_tg_id = disconnect_project_by_tg_id diff --git a/src/usecase/analytics/get_placements_analytics.py b/src/usecase/analytics/get_placements_analytics.py index f0fd97f..fa6a096 100644 --- a/src/usecase/analytics/get_placements_analytics.py +++ b/src/usecase/analytics/get_placements_analytics.py @@ -53,12 +53,20 @@ async def get_placements_analytics( allowed_project_ids = None placements = await self.database.get_workspace_placement_posts( - input.workspace_id, - input.project_id, + workspace_id=input.workspace_id, + project_id=input.project_id, + placement_channel_id=input.placement_channel_id, + creative_id=input.creative_id, + date_from=input.date_from, + date_to=input.date_to, include_archived=False, allowed_project_ids=allowed_project_ids, + has_post=True, ) + if not placements: + return [] + # Batch fetch views data for all posts post_ids = [p.post.id for p in placements if p.post] views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {} @@ -76,6 +84,14 @@ async def get_placements_analytics( if not placement.project.channel: log.warning('Placement %s missing project channel', placement.id) continue + + # Generate post URL + post_url = None + if placement_post.post: + post = placement_post.post + if post.channel.username: + post_url = f'https://t.me/{post.channel.username}/{post.message_id}' + cost = _get_cost(placement_post) subs_count = subscriptions_counts.get(placement_post.id, 0) if placement_post.post and placement_post.post.id in views_map: @@ -97,6 +113,7 @@ async def get_placements_analytics( views_count=views_count, cpf=_calculate_cpf(cost, subs_count), cpm=_calculate_cpm(cost, views_count), + post_url=post_url, ) ) return results diff --git a/src/usecase/analytics/get_spending_analytics.py b/src/usecase/analytics/get_spending_analytics.py index 4f8da83..48b9aa8 100644 --- a/src/usecase/analytics/get_spending_analytics.py +++ b/src/usecase/analytics/get_spending_analytics.py @@ -119,6 +119,13 @@ async def get_spending_analytics( 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 + # Count unique placements + unique_placements = set() + for p, _ in filtered: + if p.placement: + unique_placements.add(p.placement.id) + placements_count = len(unique_placements) + # Данные для графика chart_data = [] for period in sorted(period_data.keys()): @@ -149,4 +156,5 @@ async def get_spending_analytics( avg_cpf=avg_cpf, avg_cpm=avg_cpm, chart_data=chart_data, + placements_count=placements_count, ) diff --git a/src/usecase/project/archive_project.py b/src/usecase/project/archive_project.py index 1b367ff..dd4fd87 100644 --- a/src/usecase/project/archive_project.py +++ b/src/usecase/project/archive_project.py @@ -28,3 +28,27 @@ async def archive_project(self: 'Usecase', input: dto.ArchiveProjectInput) -> dt status=project.status, purchase_invite_type_default=project.purchase_invite_type_default, ) + + +async def unarchive_project(self: 'Usecase', input: dto.ArchiveProjectInput) -> dto.ProjectOutput: + await self.ensure_workspace_permission( + input.workspace_id, input.user_id, domain.PermissionKey.PROJECTS_WRITE, for_project_id=input.project_id + ) + + async with self.database.transaction(): + await self.database.unarchive_project(input.workspace_id, input.project_id) + project = await self.database.get_project(input.workspace_id, project_id=input.project_id) + + if not project: + raise domain.ProjectNotFound() + + await project.fetch_related('channel') + + return dto.ProjectOutput( + id=project.id, + telegram_id=project.channel.telegram_id, + title=project.channel.title, + username=project.channel.username, + status=project.status, + purchase_invite_type_default=project.purchase_invite_type_default, + )