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
|
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',
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user