feat: add unarchive_project endpoint & placements_count to spending analytics
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user