feat: add unarchive_project endpoint & placements_count to spending analytics

This commit is contained in:
ivannoskov
2026-01-21 22:44:02 +03:00
parent a280d4b190
commit 97e41934aa
8 changed files with 93 additions and 3 deletions

View File

@@ -322,6 +322,14 @@ class Postgres(DatabaseBase):
project.status = domain.ProjectStatus.ARCHIVED project.status = domain.ProjectStatus.ARCHIVED
await project.save() 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 @staticmethod
async def delete_project(workspace_id: uuid.UUID, project_id: uuid.UUID) -> None: 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) 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, allowed_project_ids: set[uuid.UUID] | None = None,
date_from: datetime.datetime | None = None, date_from: datetime.datetime | None = None,
date_to: datetime.datetime | None = None, date_to: datetime.datetime | None = None,
has_post: bool = False,
) -> list[domain.PlacementPost]: ) -> list[domain.PlacementPost]:
query = domain.PlacementPost.filter(placement__project__workspace_id=workspace_id) query = domain.PlacementPost.filter(placement__project__workspace_id=workspace_id)
@@ -464,6 +473,9 @@ class Postgres(DatabaseBase):
if date_to: if date_to:
query = query.filter(created_at__lte=date_to) query = query.filter(created_at__lte=date_to)
if has_post:
query = query.filter(post__isnull=False)
return ( return (
await query.prefetch_related( await query.prefetch_related(
'placement', 'placement',

View File

@@ -17,11 +17,19 @@ async def get_placements_analytics(
workspace_id: uuid.UUID, workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
project_id: uuid.UUID | None = None, 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]: ) -> Page[dto.PlacementAnalyticsOutput]:
input = dto.GetPlacementsAnalyticsInput( input = dto.GetPlacementsAnalyticsInput(
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_id, workspace_id=workspace_id,
project_id=project_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) result = await deps.get_usecase().get_placements_analytics(input)
return paginate(result) # type: ignore[no-any-return] return paginate(result) # type: ignore[no-any-return]

View File

@@ -75,6 +75,20 @@ async def archive_project(
return await deps.get_usecase().archive_project(input=input) 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}') @projects_router.delete('/{project_id}')
async def delete_project( async def delete_project(
workspace_id: uuid.UUID, workspace_id: uuid.UUID,

View File

@@ -48,6 +48,7 @@ class PlacementAnalyticsOutput(pydantic.BaseModel):
views_count: int | None views_count: int | None
cpf: float | None cpf: float | None
cpm: float | None cpm: float | None
post_url: str | None = None
class CreativeAnalyticsOutput(pydantic.BaseModel): class CreativeAnalyticsOutput(pydantic.BaseModel):
@@ -77,6 +78,10 @@ class GetPlacementsAnalyticsInput(pydantic.BaseModel):
user_id: uuid.UUID user_id: uuid.UUID
workspace_id: uuid.UUID workspace_id: uuid.UUID
project_id: uuid.UUID | None = None 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): class GetPlacementsAnalyticsOutput(pydantic.BaseModel):
@@ -128,6 +133,7 @@ class GetSpendingAnalyticsOutput(pydantic.BaseModel):
avg_cpf: float | None avg_cpf: float | None
avg_cpm: float | None avg_cpm: float | None
chart_data: list[SpendingDataPoint] chart_data: list[SpendingDataPoint]
placements_count: int = 0
class NumberWithDelta(pydantic.BaseModel): class NumberWithDelta(pydantic.BaseModel):

View File

@@ -30,7 +30,7 @@ from .creative.get_creative import get_creative
from .creative.get_creatives import get_creatives from .creative.get_creatives import get_creatives
from .creative.update_creative import update_creative from .creative.update_creative import update_creative
from .placement.fetch_placement_post_cycle import fetch_placement_post_cycle 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.delete_project import delete_project
from .project.disconnect_project_by_tg_id import disconnect_project_by_tg_id from .project.disconnect_project_by_tg_id import disconnect_project_by_tg_id
from .project.get_project import get_project from .project.get_project import get_project
@@ -183,6 +183,7 @@ class Usecase:
get_workspace_projects = get_workspace_projects get_workspace_projects = get_workspace_projects
get_project = get_project get_project = get_project
archive_project = archive_project archive_project = archive_project
unarchive_project = unarchive_project
delete_project = delete_project delete_project = delete_project
move_project_to_workspace = move_project_to_workspace move_project_to_workspace = move_project_to_workspace
disconnect_project_by_tg_id = disconnect_project_by_tg_id disconnect_project_by_tg_id = disconnect_project_by_tg_id

View File

@@ -53,12 +53,20 @@ async def get_placements_analytics(
allowed_project_ids = None allowed_project_ids = None
placements = await self.database.get_workspace_placement_posts( placements = await self.database.get_workspace_placement_posts(
input.workspace_id, workspace_id=input.workspace_id,
input.project_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, include_archived=False,
allowed_project_ids=allowed_project_ids, allowed_project_ids=allowed_project_ids,
has_post=True,
) )
if not placements:
return []
# Batch fetch views data for all posts # Batch fetch views data for all posts
post_ids = [p.post.id for p in placements if p.post] 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 {} 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: if not placement.project.channel:
log.warning('Placement %s missing project channel', placement.id) log.warning('Placement %s missing project channel', placement.id)
continue 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) cost = _get_cost(placement_post)
subs_count = subscriptions_counts.get(placement_post.id, 0) subs_count = subscriptions_counts.get(placement_post.id, 0)
if placement_post.post and placement_post.post.id in views_map: 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, views_count=views_count,
cpf=_calculate_cpf(cost, subs_count), cpf=_calculate_cpf(cost, subs_count),
cpm=_calculate_cpm(cost, views_count), cpm=_calculate_cpm(cost, views_count),
post_url=post_url,
) )
) )
return results return results

View File

@@ -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_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 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 = [] chart_data = []
for period in sorted(period_data.keys()): for period in sorted(period_data.keys()):
@@ -149,4 +156,5 @@ async def get_spending_analytics(
avg_cpf=avg_cpf, avg_cpf=avg_cpf,
avg_cpm=avg_cpm, avg_cpm=avg_cpm,
chart_data=chart_data, chart_data=chart_data,
placements_count=placements_count,
) )

View File

@@ -28,3 +28,27 @@ async def archive_project(self: 'Usecase', input: dto.ArchiveProjectInput) -> dt
status=project.status, status=project.status,
purchase_invite_type_default=project.purchase_invite_type_default, 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,
)