права фикс
This commit is contained in:
149
migrations/models/10_20260127194137_update.py
Normal file
149
migrations/models/10_20260127194137_update.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
from tortoise import BaseDBAsyncClient
|
||||||
|
|
||||||
|
RUN_IN_TRANSACTION = True
|
||||||
|
|
||||||
|
|
||||||
|
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
return """
|
||||||
|
DROP INDEX IF EXISTS "idx_workspace_u_channel_d94b48";
|
||||||
|
ALTER TABLE "workspace_user_permission_scope" DROP CONSTRAINT IF EXISTS "fk_workspac_channel_e039fdfd";
|
||||||
|
ALTER TABLE "creative" ADD "created_by_user_id" UUID;
|
||||||
|
COMMENT ON COLUMN "workspace_user_permission"."permission" IS 'ADMIN_FULL: admin_full
|
||||||
|
PROJECTS_READ: projects_read
|
||||||
|
PROJECTS_WRITE: projects_write
|
||||||
|
CREATIVES_READ: creatives_read
|
||||||
|
CREATIVES_WRITE: creatives_write
|
||||||
|
PLACEMENTS_READ: placements_read
|
||||||
|
PLACEMENTS_WRITE: placements_write
|
||||||
|
ANALYTICS_READ: analytics_read
|
||||||
|
ANALYTICS_WITHOUT_CLICKS: analytics_without_clicks
|
||||||
|
ANALYTICS_OWN_CREATIVES: analytics_own_creatives';
|
||||||
|
ALTER TABLE "workspace_user_permission_scope" DROP COLUMN "channel_id";
|
||||||
|
COMMENT ON COLUMN "workspace_user_permission_scope"."permission" IS 'ADMIN_FULL: admin_full
|
||||||
|
PROJECTS_READ: projects_read
|
||||||
|
PROJECTS_WRITE: projects_write
|
||||||
|
CREATIVES_READ: creatives_read
|
||||||
|
CREATIVES_WRITE: creatives_write
|
||||||
|
PLACEMENTS_READ: placements_read
|
||||||
|
PLACEMENTS_WRITE: placements_write
|
||||||
|
ANALYTICS_READ: analytics_read
|
||||||
|
ANALYTICS_WITHOUT_CLICKS: analytics_without_clicks
|
||||||
|
ANALYTICS_OWN_CREATIVES: analytics_own_creatives';
|
||||||
|
ALTER TABLE "creative" ADD CONSTRAINT "fk_creative_user_2ed7d825" FOREIGN KEY ("created_by_user_id") REFERENCES "user" ("id") ON DELETE SET NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_creative_created_2d9564" ON "creative" ("created_by_user_id");
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "uid_creative_me_creativ_b07496" ON "creative_media" ("creative_id", "position");"""
|
||||||
|
|
||||||
|
|
||||||
|
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
return """
|
||||||
|
DROP INDEX IF EXISTS "uid_creative_me_creativ_b07496";
|
||||||
|
DROP INDEX IF EXISTS "idx_creative_created_2d9564";
|
||||||
|
ALTER TABLE "creative" DROP CONSTRAINT IF EXISTS "fk_creative_user_2ed7d825";
|
||||||
|
ALTER TABLE "creative" DROP COLUMN "created_by_user_id";
|
||||||
|
COMMENT ON COLUMN "workspace_user_permission"."permission" IS 'ADMIN_FULL: admin_full
|
||||||
|
PROJECTS_READ: projects_read
|
||||||
|
PROJECTS_WRITE: projects_write
|
||||||
|
CREATIVES_READ: creatives_read
|
||||||
|
CREATIVES_WRITE: creatives_write
|
||||||
|
PLACEMENTS_READ: placements_read
|
||||||
|
PLACEMENTS_WRITE: placements_write
|
||||||
|
ANALYTICS_READ: analytics_read
|
||||||
|
ANALYTICS_WITHOUT_CLICKS: analytics_without_clicks
|
||||||
|
CHANNELS_READ: channels_read
|
||||||
|
CHANNELS_WRITE: channels_write';
|
||||||
|
ALTER TABLE "workspace_user_permission_scope" ADD "channel_id" UUID;
|
||||||
|
COMMENT ON COLUMN "workspace_user_permission_scope"."permission" IS 'ADMIN_FULL: admin_full
|
||||||
|
PROJECTS_READ: projects_read
|
||||||
|
PROJECTS_WRITE: projects_write
|
||||||
|
CREATIVES_READ: creatives_read
|
||||||
|
CREATIVES_WRITE: creatives_write
|
||||||
|
PLACEMENTS_READ: placements_read
|
||||||
|
PLACEMENTS_WRITE: placements_write
|
||||||
|
ANALYTICS_READ: analytics_read
|
||||||
|
ANALYTICS_WITHOUT_CLICKS: analytics_without_clicks
|
||||||
|
CHANNELS_READ: channels_read
|
||||||
|
CHANNELS_WRITE: channels_write';
|
||||||
|
ALTER TABLE "workspace_user_permission_scope" ADD CONSTRAINT "fk_workspac_channel_e039fdfd" FOREIGN KEY ("channel_id") REFERENCES "channel" ("id") ON DELETE CASCADE;
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_workspace_u_channel_d94b48" ON "workspace_user_permission_scope" ("channel_id");"""
|
||||||
|
|
||||||
|
|
||||||
|
MODELS_STATE = (
|
||||||
|
"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"
|
||||||
|
"6H29Y3Htd2t3/Nr7ZJBtfJboLLf3p5/xfKHDK4"
|
||||||
|
)
|
||||||
@@ -361,6 +361,7 @@ class Postgres(DatabaseBase):
|
|||||||
project_id: uuid.UUID | None = None,
|
project_id: uuid.UUID | None = None,
|
||||||
include_archived: bool = False,
|
include_archived: bool = False,
|
||||||
allowed_project_ids: set[uuid.UUID] | None = None,
|
allowed_project_ids: set[uuid.UUID] | None = None,
|
||||||
|
created_by_user_id: uuid.UUID | None = None,
|
||||||
) -> list[domain.Creative]:
|
) -> list[domain.Creative]:
|
||||||
query = domain.Creative.filter(project__workspace_id=workspace_id)
|
query = domain.Creative.filter(project__workspace_id=workspace_id)
|
||||||
|
|
||||||
@@ -371,6 +372,9 @@ class Postgres(DatabaseBase):
|
|||||||
return []
|
return []
|
||||||
query = query.filter(project_id__in=list(allowed_project_ids))
|
query = query.filter(project_id__in=list(allowed_project_ids))
|
||||||
|
|
||||||
|
if created_by_user_id is not None:
|
||||||
|
query = query.filter(created_by_user_id=created_by_user_id)
|
||||||
|
|
||||||
if not include_archived:
|
if not include_archived:
|
||||||
query = query.filter(status=domain.CreativeStatus.ACTIVE)
|
query = query.filter(status=domain.CreativeStatus.ACTIVE)
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from .error import (
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .project import Project
|
from .project import Project
|
||||||
|
from .user import User
|
||||||
|
|
||||||
|
|
||||||
class CreativeStatus(str, enum.Enum):
|
class CreativeStatus(str, enum.Enum):
|
||||||
@@ -32,9 +33,13 @@ class Creative(TimestampedModel):
|
|||||||
project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
|
project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
|
||||||
'models.Project', related_name='creatives', on_delete=fields.CASCADE, index=True
|
'models.Project', related_name='creatives', on_delete=fields.CASCADE, index=True
|
||||||
)
|
)
|
||||||
|
created_by_user: fields.ForeignKeyRelation['User'] | None = fields.ForeignKeyField(
|
||||||
|
'models.User', related_name='created_creatives', on_delete=fields.SET_NULL, null=True, index=True
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
project_id: UUID
|
project_id: UUID
|
||||||
|
created_by_user_id: UUID | None
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table = 'creative'
|
table = 'creative'
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from .base import TimestampedModel
|
|||||||
from .error import WorkspaceAvatarTooLarge
|
from .error import WorkspaceAvatarTooLarge
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .channel import Channel
|
|
||||||
from .creative import Creative
|
from .creative import Creative
|
||||||
from .placement import Placement
|
from .placement import Placement
|
||||||
from .project import Project
|
from .project import Project
|
||||||
@@ -94,16 +93,28 @@ class PermissionKey(enum.StrEnum):
|
|||||||
|
|
||||||
ANALYTICS_READ = 'analytics_read'
|
ANALYTICS_READ = 'analytics_read'
|
||||||
ANALYTICS_WITHOUT_CLICKS = 'analytics_without_clicks'
|
ANALYTICS_WITHOUT_CLICKS = 'analytics_without_clicks'
|
||||||
|
ANALYTICS_OWN_CREATIVES = 'analytics_own_creatives'
|
||||||
|
|
||||||
CHANNELS_READ = 'channels_read'
|
@property
|
||||||
CHANNELS_WRITE = 'channels_write'
|
def description(self) -> str:
|
||||||
|
return {
|
||||||
|
PermissionKey.ADMIN_FULL: 'полный доступ',
|
||||||
|
PermissionKey.PROJECTS_READ: 'просмотр каталога каналов (проектов)',
|
||||||
|
PermissionKey.PROJECTS_WRITE: 'редактирование каталога каналов',
|
||||||
|
PermissionKey.CREATIVES_READ: 'просмотр креативов',
|
||||||
|
PermissionKey.CREATIVES_WRITE: 'создание/редактирование креативов',
|
||||||
|
PermissionKey.PLACEMENTS_READ: 'просмотр планов закупок',
|
||||||
|
PermissionKey.PLACEMENTS_WRITE: 'создание/редактирование закупок',
|
||||||
|
PermissionKey.ANALYTICS_READ: 'полный доступ к статистике',
|
||||||
|
PermissionKey.ANALYTICS_WITHOUT_CLICKS: 'статистика кроме переходов',
|
||||||
|
PermissionKey.ANALYTICS_OWN_CREATIVES: 'статистика только по своим креативам',
|
||||||
|
}.get(self, self.value)
|
||||||
|
|
||||||
|
|
||||||
class PermissionScopeType(enum.StrEnum):
|
class PermissionScopeType(enum.StrEnum):
|
||||||
PROJECT = 'project'
|
PROJECT = 'project'
|
||||||
CREATIVE = 'creative'
|
CREATIVE = 'creative'
|
||||||
PLACEMENT = 'placement'
|
PLACEMENT = 'placement'
|
||||||
CHANNEL = 'channel'
|
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceUserPermission(TimestampedModel):
|
class WorkspaceUserPermission(TimestampedModel):
|
||||||
@@ -138,16 +149,12 @@ class WorkspaceUserPermissionScope(TimestampedModel):
|
|||||||
placement: fields.ForeignKeyRelation[Placement] | None = fields.ForeignKeyField(
|
placement: fields.ForeignKeyRelation[Placement] | None = fields.ForeignKeyField(
|
||||||
'models.Placement', related_name='permission_scopes', on_delete=fields.CASCADE, null=True, index=True
|
'models.Placement', related_name='permission_scopes', on_delete=fields.CASCADE, null=True, index=True
|
||||||
)
|
)
|
||||||
channel: fields.ForeignKeyRelation[Channel] | None = fields.ForeignKeyField(
|
|
||||||
'models.Channel', related_name='permission_scopes', on_delete=fields.CASCADE, null=True, index=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
workspace_user_id: uuid.UUID
|
workspace_user_id: uuid.UUID
|
||||||
project_id: uuid.UUID | None
|
project_id: uuid.UUID | None
|
||||||
creative_id: uuid.UUID | None
|
creative_id: uuid.UUID | None
|
||||||
placement_id: uuid.UUID | None
|
placement_id: uuid.UUID | None
|
||||||
channel_id: uuid.UUID | None
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table = 'workspace_user_permission_scope'
|
table = 'workspace_user_permission_scope'
|
||||||
|
|||||||
@@ -32,9 +32,6 @@ class WorkspacePermissions:
|
|||||||
elif scope.placement_id:
|
elif scope.placement_id:
|
||||||
key = (scope.permission, PermissionScopeType.PLACEMENT)
|
key = (scope.permission, PermissionScopeType.PLACEMENT)
|
||||||
scoped_permissions.setdefault(key, set()).add(scope.placement_id)
|
scoped_permissions.setdefault(key, set()).add(scope.placement_id)
|
||||||
elif scope.channel_id:
|
|
||||||
key = (scope.permission, PermissionScopeType.CHANNEL)
|
|
||||||
scoped_permissions.setdefault(key, set()).add(scope.channel_id)
|
|
||||||
|
|
||||||
return cls(global_permissions=global_permissions, scoped_permissions=scoped_permissions)
|
return cls(global_permissions=global_permissions, scoped_permissions=scoped_permissions)
|
||||||
|
|
||||||
@@ -77,18 +74,6 @@ class WorkspacePermissions:
|
|||||||
|
|
||||||
return allowed
|
return allowed
|
||||||
|
|
||||||
def allowed_channel_ids(self, permission: PermissionKey) -> set[UUID] | None:
|
|
||||||
if self.has_global(permission):
|
|
||||||
return None
|
|
||||||
|
|
||||||
allowed: set[UUID] = set()
|
|
||||||
for key in (permission, PermissionKey.ADMIN_FULL):
|
|
||||||
channel_scope = self.scoped_permissions.get((key, PermissionScopeType.CHANNEL))
|
|
||||||
if channel_scope:
|
|
||||||
allowed.update(channel_scope)
|
|
||||||
|
|
||||||
return allowed
|
|
||||||
|
|
||||||
def has_permission(
|
def has_permission(
|
||||||
self,
|
self,
|
||||||
permission: PermissionKey,
|
permission: PermissionKey,
|
||||||
@@ -106,8 +91,6 @@ class WorkspacePermissions:
|
|||||||
allowed = self.allowed_creative_ids(permission)
|
allowed = self.allowed_creative_ids(permission)
|
||||||
elif scope_type == PermissionScopeType.PLACEMENT:
|
elif scope_type == PermissionScopeType.PLACEMENT:
|
||||||
allowed = self.allowed_placement_ids(permission)
|
allowed = self.allowed_placement_ids(permission)
|
||||||
elif scope_type == PermissionScopeType.CHANNEL:
|
|
||||||
allowed = self.allowed_channel_ids(permission)
|
|
||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -123,6 +106,28 @@ class WorkspacePermissions:
|
|||||||
return True
|
return True
|
||||||
return bool(allowed)
|
return bool(allowed)
|
||||||
|
|
||||||
|
def has_any_analytics_permission(self) -> bool:
|
||||||
|
"""Check if user has any analytics permission."""
|
||||||
|
return (
|
||||||
|
self.has_global(PermissionKey.ANALYTICS_READ)
|
||||||
|
or self.has_global(PermissionKey.ANALYTICS_WITHOUT_CLICKS)
|
||||||
|
or self.has_global(PermissionKey.ANALYTICS_OWN_CREATIVES)
|
||||||
|
)
|
||||||
|
|
||||||
|
def should_hide_subscriptions(self) -> bool:
|
||||||
|
"""Check if subscription data should be hidden (user has analytics_without_clicks but not analytics_read)."""
|
||||||
|
if self.has_global(PermissionKey.ANALYTICS_READ):
|
||||||
|
return False
|
||||||
|
return self.has_global(PermissionKey.ANALYTICS_WITHOUT_CLICKS)
|
||||||
|
|
||||||
|
def should_filter_own_creatives(self) -> bool:
|
||||||
|
"""Check if analytics should be filtered to user's own creatives only."""
|
||||||
|
if self.has_global(PermissionKey.ANALYTICS_READ):
|
||||||
|
return False
|
||||||
|
if self.has_global(PermissionKey.ANALYTICS_WITHOUT_CLICKS):
|
||||||
|
return False
|
||||||
|
return self.has_global(PermissionKey.ANALYTICS_OWN_CREATIVES)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class WorkspacePermissionContext:
|
class WorkspacePermissionContext:
|
||||||
@@ -139,9 +144,6 @@ class WorkspacePermissionContext:
|
|||||||
def allowed_placement_ids(self, permission: PermissionKey) -> set[UUID] | None:
|
def allowed_placement_ids(self, permission: PermissionKey) -> set[UUID] | None:
|
||||||
return self.permissions.allowed_placement_ids(permission)
|
return self.permissions.allowed_placement_ids(permission)
|
||||||
|
|
||||||
def allowed_channel_ids(self, permission: PermissionKey) -> set[UUID] | None:
|
|
||||||
return self.permissions.allowed_channel_ids(permission)
|
|
||||||
|
|
||||||
def ensure_project_permission(self, permission: PermissionKey, project_id: UUID) -> None:
|
def ensure_project_permission(self, permission: PermissionKey, project_id: UUID) -> None:
|
||||||
if not self.permissions.has_permission(
|
if not self.permissions.has_permission(
|
||||||
permission,
|
permission,
|
||||||
@@ -166,13 +168,13 @@ class WorkspacePermissionContext:
|
|||||||
):
|
):
|
||||||
raise WorkspaceAccessDenied(self.workspace.id)
|
raise WorkspaceAccessDenied(self.workspace.id)
|
||||||
|
|
||||||
def ensure_channel_permission(self, permission: PermissionKey, channel_id: UUID) -> None:
|
def should_hide_subscriptions(self) -> bool:
|
||||||
if not self.permissions.has_permission(
|
"""Check if subscription data should be hidden."""
|
||||||
permission,
|
return self.permissions.should_hide_subscriptions()
|
||||||
scope_type=PermissionScopeType.CHANNEL,
|
|
||||||
scope_id=channel_id,
|
def should_filter_own_creatives(self) -> bool:
|
||||||
):
|
"""Check if analytics should be filtered to user's own creatives only."""
|
||||||
raise WorkspaceAccessDenied(self.workspace.id)
|
return self.permissions.should_filter_own_creatives()
|
||||||
|
|
||||||
|
|
||||||
def build_workspace_permission_context(membership: WorkspaceUser) -> WorkspacePermissionContext:
|
def build_workspace_permission_context(membership: WorkspaceUser) -> WorkspacePermissionContext:
|
||||||
|
|||||||
@@ -74,9 +74,6 @@ class WorkspaceMemberOutput(pydantic.BaseModel):
|
|||||||
elif scope.placement_id:
|
elif scope.placement_id:
|
||||||
scope_type = domain.PermissionScopeType.PLACEMENT
|
scope_type = domain.PermissionScopeType.PLACEMENT
|
||||||
scope_id = scope.placement_id
|
scope_id = scope.placement_id
|
||||||
elif scope.channel_id:
|
|
||||||
scope_type = domain.PermissionScopeType.CHANNEL
|
|
||||||
scope_id = scope.channel_id
|
|
||||||
|
|
||||||
if scope_type and scope_id:
|
if scope_type and scope_id:
|
||||||
permissions_map.setdefault(scope.permission, []).append(
|
permissions_map.setdefault(scope.permission, []).append(
|
||||||
@@ -112,8 +109,8 @@ class WorkspacePermissionScopeInput(pydantic.BaseModel):
|
|||||||
class WorkspacePermissionInput(pydantic.BaseModel):
|
class WorkspacePermissionInput(pydantic.BaseModel):
|
||||||
key: domain.PermissionKey = pydantic.Field(
|
key: domain.PermissionKey = pydantic.Field(
|
||||||
description=(
|
description=(
|
||||||
'Ключ права. Доступные значения: "projects_read", "projects_write", '
|
'Ключ права. Доступные значения: '
|
||||||
'"placements_read", "placements_write", "analytics_read", "admin_full".'
|
+ ', '.join(f'"{k.value}" - {k.description}' for k in domain.PermissionKey)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
scopes: list[WorkspacePermissionScopeInput] = pydantic.Field(
|
scopes: list[WorkspacePermissionScopeInput] = pydantic.Field(
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ app.add_middleware(
|
|||||||
allow_origins=[
|
allow_origins=[
|
||||||
'https://app.tgexchange.ru',
|
'https://app.tgexchange.ru',
|
||||||
'http://app.tgexchange.ru',
|
'http://app.tgexchange.ru',
|
||||||
|
'http://localhost:3000',
|
||||||
],
|
],
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=['*'],
|
allow_methods=['*'],
|
||||||
|
|||||||
@@ -164,6 +164,21 @@ class Usecase:
|
|||||||
|
|
||||||
return domain.build_workspace_permission_context(membership)
|
return domain.build_workspace_permission_context(membership)
|
||||||
|
|
||||||
|
async def ensure_analytics_permission(
|
||||||
|
self, workspace_id: UUID, user_id: UUID, *, for_project_id: UUID | None = None
|
||||||
|
) -> domain.WorkspacePermissionContext:
|
||||||
|
"""Ensure user has any analytics permission."""
|
||||||
|
membership = await self.database.get_workspace_membership(workspace_id, user_id)
|
||||||
|
if not membership or not membership.workspace:
|
||||||
|
raise domain.WorkspaceNotFound(workspace_id)
|
||||||
|
|
||||||
|
permissions = domain.WorkspacePermissions.from_membership(membership)
|
||||||
|
|
||||||
|
if not permissions.has_any_analytics_permission():
|
||||||
|
raise domain.WorkspaceAccessDenied(workspace_id)
|
||||||
|
|
||||||
|
return domain.build_workspace_permission_context(membership)
|
||||||
|
|
||||||
async def get_or_create_personal_workspace(self, user: domain.User) -> domain.Workspace:
|
async def get_or_create_personal_workspace(self, user: domain.User) -> domain.Workspace:
|
||||||
workspace = await self.database.get_default_workspace_for_user(user.id)
|
workspace = await self.database.get_default_workspace_for_user(user.id)
|
||||||
if workspace:
|
if workspace:
|
||||||
|
|||||||
@@ -19,17 +19,15 @@ def _get_cost(placement_post: domain.PlacementPost) -> float:
|
|||||||
async def get_channel_analytics(
|
async def get_channel_analytics(
|
||||||
self: 'Usecase', input: dto.GetChannelAnalyticsInput
|
self: 'Usecase', input: dto.GetChannelAnalyticsInput
|
||||||
) -> list[dto.ChannelAnalyticsOutput]:
|
) -> list[dto.ChannelAnalyticsOutput]:
|
||||||
context = await self.ensure_workspace_permission(
|
context = await self.ensure_analytics_permission(input.workspace_id, input.user_id)
|
||||||
input.workspace_id, input.user_id, domain.PermissionKey.ANALYTICS_READ
|
|
||||||
)
|
|
||||||
|
|
||||||
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ)
|
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ)
|
||||||
|
hide_subscriptions = context.should_hide_subscriptions()
|
||||||
|
|
||||||
if input.project_id:
|
if input.project_id:
|
||||||
project = await self.database.get_project(input.workspace_id, input.project_id)
|
project = await self.database.get_project(input.workspace_id, input.project_id)
|
||||||
if not project:
|
if not project:
|
||||||
raise domain.ProjectNotFound(input.project_id)
|
raise domain.ProjectNotFound(input.project_id)
|
||||||
context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id)
|
|
||||||
allowed_project_filter = None
|
allowed_project_filter = None
|
||||||
else:
|
else:
|
||||||
allowed_project_filter = allowed_project_ids
|
allowed_project_filter = allowed_project_ids
|
||||||
@@ -88,11 +86,10 @@ async def get_channel_analytics(
|
|||||||
for ch in channels:
|
for ch in channels:
|
||||||
stats = channel_stats[ch.id]
|
stats = channel_stats[ch.id]
|
||||||
|
|
||||||
avg_cpf = (
|
total_subscriptions = 0 if hide_subscriptions else stats.total_subscriptions
|
||||||
(stats.total_cost / stats.total_subscriptions)
|
avg_cpf = None
|
||||||
if stats.total_subscriptions > 0 and stats.total_cost > 0
|
if not hide_subscriptions and stats.total_subscriptions > 0 and stats.total_cost > 0:
|
||||||
else None
|
avg_cpf = stats.total_cost / stats.total_subscriptions
|
||||||
)
|
|
||||||
avg_cpm = (
|
avg_cpm = (
|
||||||
(stats.total_cost / stats.total_views * 1000) if stats.total_views > 0 and stats.total_cost > 0 else None
|
(stats.total_cost / stats.total_views * 1000) if stats.total_views > 0 and stats.total_cost > 0 else None
|
||||||
)
|
)
|
||||||
@@ -104,7 +101,7 @@ async def get_channel_analytics(
|
|||||||
username=ch.username,
|
username=ch.username,
|
||||||
placements_count=stats.placements_count,
|
placements_count=stats.placements_count,
|
||||||
total_cost=stats.total_cost,
|
total_cost=stats.total_cost,
|
||||||
total_subscriptions=stats.total_subscriptions,
|
total_subscriptions=total_subscriptions,
|
||||||
total_views=stats.total_views,
|
total_views=stats.total_views,
|
||||||
avg_cpf=avg_cpf,
|
avg_cpf=avg_cpf,
|
||||||
avg_cpm=avg_cpm,
|
avg_cpm=avg_cpm,
|
||||||
|
|||||||
@@ -19,17 +19,21 @@ def _get_cost(placement_post: domain.PlacementPost) -> float:
|
|||||||
async def get_creatives_analytics(
|
async def get_creatives_analytics(
|
||||||
self: 'Usecase', input: dto.GetCreativesAnalyticsInput
|
self: 'Usecase', input: dto.GetCreativesAnalyticsInput
|
||||||
) -> list[dto.CreativeAnalyticsOutput]:
|
) -> list[dto.CreativeAnalyticsOutput]:
|
||||||
context = await self.ensure_workspace_permission(
|
context = await self.ensure_analytics_permission(input.workspace_id, input.user_id)
|
||||||
input.workspace_id, input.user_id, domain.PermissionKey.ANALYTICS_READ
|
|
||||||
)
|
|
||||||
|
|
||||||
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ)
|
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ)
|
||||||
|
hide_subscriptions = context.should_hide_subscriptions()
|
||||||
|
filter_own_creatives = context.should_filter_own_creatives()
|
||||||
|
|
||||||
|
# Get user_id for own creatives filter
|
||||||
|
created_by_filter: UUID | None = None
|
||||||
|
if filter_own_creatives:
|
||||||
|
created_by_filter = context.membership.user_id
|
||||||
|
|
||||||
if input.project_id:
|
if input.project_id:
|
||||||
project = await self.database.get_project(input.workspace_id, input.project_id)
|
project = await self.database.get_project(input.workspace_id, input.project_id)
|
||||||
if not project:
|
if not project:
|
||||||
raise domain.ProjectNotFound(input.project_id)
|
raise domain.ProjectNotFound(input.project_id)
|
||||||
context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id)
|
|
||||||
allowed_project_ids = None
|
allowed_project_ids = None
|
||||||
|
|
||||||
creatives = await self.database.get_workspace_creatives(
|
creatives = await self.database.get_workspace_creatives(
|
||||||
@@ -37,6 +41,7 @@ async def get_creatives_analytics(
|
|||||||
input.project_id,
|
input.project_id,
|
||||||
include_archived=False,
|
include_archived=False,
|
||||||
allowed_project_ids=allowed_project_ids,
|
allowed_project_ids=allowed_project_ids,
|
||||||
|
created_by_user_id=created_by_filter,
|
||||||
)
|
)
|
||||||
placements = await self.database.get_workspace_placement_posts(
|
placements = await self.database.get_workspace_placement_posts(
|
||||||
input.workspace_id,
|
input.workspace_id,
|
||||||
@@ -84,11 +89,10 @@ async def get_creatives_analytics(
|
|||||||
for cr in creatives:
|
for cr in creatives:
|
||||||
stats = creative_stats[cr.id]
|
stats = creative_stats[cr.id]
|
||||||
|
|
||||||
avg_cpf = (
|
total_subscriptions = 0 if hide_subscriptions else stats.total_subscriptions
|
||||||
(stats.total_cost / stats.total_subscriptions)
|
avg_cpf = None
|
||||||
if stats.total_subscriptions > 0 and stats.total_cost > 0
|
if not hide_subscriptions and stats.total_subscriptions > 0 and stats.total_cost > 0:
|
||||||
else None
|
avg_cpf = stats.total_cost / stats.total_subscriptions
|
||||||
)
|
|
||||||
avg_cpm = (
|
avg_cpm = (
|
||||||
(stats.total_cost / stats.total_views * 1000) if stats.total_views > 0 and stats.total_cost > 0 else None
|
(stats.total_cost / stats.total_views * 1000) if stats.total_views > 0 and stats.total_cost > 0 else None
|
||||||
)
|
)
|
||||||
@@ -99,7 +103,7 @@ async def get_creatives_analytics(
|
|||||||
name=cr.name,
|
name=cr.name,
|
||||||
placements_count=stats.placements_count,
|
placements_count=stats.placements_count,
|
||||||
total_cost=stats.total_cost,
|
total_cost=stats.total_cost,
|
||||||
total_subscriptions=stats.total_subscriptions,
|
total_subscriptions=total_subscriptions,
|
||||||
total_views=stats.total_views,
|
total_views=stats.total_views,
|
||||||
avg_cpf=avg_cpf,
|
avg_cpf=avg_cpf,
|
||||||
avg_cpm=avg_cpm,
|
avg_cpm=avg_cpm,
|
||||||
|
|||||||
@@ -49,17 +49,15 @@ async def get_overview_analytics(
|
|||||||
if input.date_from > input.date_to:
|
if input.date_from > input.date_to:
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'date_from must be before date_to')
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'date_from must be before date_to')
|
||||||
|
|
||||||
context = await self.ensure_workspace_permission(
|
context = await self.ensure_analytics_permission(input.workspace_id, input.user_id)
|
||||||
input.workspace_id, input.user_id, domain.PermissionKey.ANALYTICS_READ
|
|
||||||
)
|
|
||||||
|
|
||||||
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ)
|
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ)
|
||||||
|
hide_subscriptions = context.should_hide_subscriptions()
|
||||||
|
|
||||||
if input.project_id:
|
if input.project_id:
|
||||||
project = await self.database.get_project(input.workspace_id, input.project_id)
|
project = await self.database.get_project(input.workspace_id, input.project_id)
|
||||||
if not project:
|
if not project:
|
||||||
raise domain.ProjectNotFound(input.project_id)
|
raise domain.ProjectNotFound(input.project_id)
|
||||||
context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id)
|
|
||||||
allowed_project_ids = None
|
allowed_project_ids = None
|
||||||
|
|
||||||
raw_duration = input.date_to - input.date_from
|
raw_duration = input.date_to - input.date_from
|
||||||
@@ -132,6 +130,13 @@ async def get_overview_analytics(
|
|||||||
current_cost, current_subs, current_views = _aggregate_totals(current_placements, subs_per_placement_current)
|
current_cost, current_subs, current_views = _aggregate_totals(current_placements, subs_per_placement_current)
|
||||||
previous_cost, previous_subs, previous_views = _aggregate_totals(previous_placements, subs_per_placement_previous)
|
previous_cost, previous_subs, previous_views = _aggregate_totals(previous_placements, subs_per_placement_previous)
|
||||||
|
|
||||||
|
# Apply hide_subscriptions filter
|
||||||
|
if hide_subscriptions:
|
||||||
|
current_subs = 0
|
||||||
|
previous_subs = 0
|
||||||
|
current_avg_cpf = None
|
||||||
|
previous_avg_cpf = None
|
||||||
|
else:
|
||||||
current_avg_cpf = current_cost / current_subs if current_cost > 0 and current_subs > 0 else None
|
current_avg_cpf = current_cost / current_subs if current_cost > 0 and current_subs > 0 else None
|
||||||
previous_avg_cpf = previous_cost / previous_subs if previous_cost > 0 and previous_subs > 0 else None
|
previous_avg_cpf = previous_cost / previous_subs if previous_cost > 0 and previous_subs > 0 else None
|
||||||
|
|
||||||
@@ -171,9 +176,9 @@ async def get_overview_analytics(
|
|||||||
for day_offset in range(days_span + 1):
|
for day_offset in range(days_span + 1):
|
||||||
day = start_date + datetime.timedelta(days=day_offset)
|
day = start_date + datetime.timedelta(days=day_offset)
|
||||||
cost = cost_per_day.get(day, 0.0)
|
cost = cost_per_day.get(day, 0.0)
|
||||||
subs = subs_per_day_current.get(day, 0)
|
subs = 0 if hide_subscriptions else subs_per_day_current.get(day, 0)
|
||||||
delta = (subs - previous_day_subs) if previous_day_subs is not None else subs
|
delta = None if hide_subscriptions else ((subs - previous_day_subs) if previous_day_subs is not None else subs)
|
||||||
cpf = cost / subs if subs > 0 and cost > 0 else None
|
cpf = None if hide_subscriptions else (cost / subs if subs > 0 and cost > 0 else None)
|
||||||
daily_stats.append(
|
daily_stats.append(
|
||||||
dto.OverviewDailyPoint(date=day, cost=cost, subscriptions=subs, subscriptions_delta=delta, cpf=cpf)
|
dto.OverviewDailyPoint(date=day, cost=cost, subscriptions=subs, subscriptions_delta=delta, cpf=cpf)
|
||||||
)
|
)
|
||||||
@@ -181,12 +186,15 @@ async def get_overview_analytics(
|
|||||||
|
|
||||||
channel_performance = []
|
channel_performance = []
|
||||||
for stats in channel_stats.values():
|
for stats in channel_stats.values():
|
||||||
subs = stats.total_subs
|
subs = 0 if hide_subscriptions else stats.total_subs
|
||||||
cost_value = stats.total_cost
|
cost_value = stats.total_cost
|
||||||
cpf = cost_value / subs if subs > 0 else None
|
cpf = None if hide_subscriptions else (cost_value / stats.total_subs if stats.total_subs > 0 else None)
|
||||||
channel = stats.channel
|
channel = stats.channel
|
||||||
|
|
||||||
if not channel or cpf is None:
|
if not channel:
|
||||||
|
continue
|
||||||
|
# Skip channels without CPF only if we're not hiding subscriptions
|
||||||
|
if not hide_subscriptions and cpf is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
channel_performance.append(
|
channel_performance.append(
|
||||||
|
|||||||
@@ -39,17 +39,15 @@ def _calculate_cpm(cost: float | None, views: int | None) -> float | None:
|
|||||||
async def get_placements_analytics(
|
async def get_placements_analytics(
|
||||||
self: 'Usecase', input: dto.GetPlacementsAnalyticsInput
|
self: 'Usecase', input: dto.GetPlacementsAnalyticsInput
|
||||||
) -> list[dto.PlacementAnalyticsOutput]:
|
) -> list[dto.PlacementAnalyticsOutput]:
|
||||||
context = await self.ensure_workspace_permission(
|
context = await self.ensure_analytics_permission(input.workspace_id, input.user_id)
|
||||||
input.workspace_id, input.user_id, domain.PermissionKey.ANALYTICS_READ
|
|
||||||
)
|
|
||||||
|
|
||||||
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ)
|
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ)
|
||||||
|
hide_subscriptions = context.should_hide_subscriptions()
|
||||||
|
|
||||||
if input.project_id:
|
if input.project_id:
|
||||||
project = await self.database.get_project(input.workspace_id, input.project_id)
|
project = await self.database.get_project(input.workspace_id, input.project_id)
|
||||||
if not project:
|
if not project:
|
||||||
raise domain.ProjectNotFound(input.project_id)
|
raise domain.ProjectNotFound(input.project_id)
|
||||||
context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id)
|
|
||||||
allowed_project_ids = None
|
allowed_project_ids = None
|
||||||
|
|
||||||
placements = await self.database.get_workspace_placement_posts(
|
placements = await self.database.get_workspace_placement_posts(
|
||||||
@@ -93,11 +91,12 @@ async def get_placements_analytics(
|
|||||||
post_url = f'https://t.me/{post.channel.username}/{post.message_id}'
|
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 = 0 if hide_subscriptions else 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:
|
||||||
views_count = views_map[placement_post.post.id][0]
|
views_count = views_map[placement_post.post.id][0]
|
||||||
else:
|
else:
|
||||||
views_count = None
|
views_count = None
|
||||||
|
cpf = None if hide_subscriptions else _calculate_cpf(cost, subs_count)
|
||||||
results.append(
|
results.append(
|
||||||
dto.PlacementAnalyticsOutput(
|
dto.PlacementAnalyticsOutput(
|
||||||
id=placement_post.id,
|
id=placement_post.id,
|
||||||
@@ -111,7 +110,7 @@ async def get_placements_analytics(
|
|||||||
cost=cost,
|
cost=cost,
|
||||||
subscriptions_count=subs_count,
|
subscriptions_count=subs_count,
|
||||||
views_count=views_count,
|
views_count=views_count,
|
||||||
cpf=_calculate_cpf(cost, subs_count),
|
cpf=cpf,
|
||||||
cpm=_calculate_cpm(cost, views_count),
|
cpm=_calculate_cpm(cost, views_count),
|
||||||
post_url=post_url,
|
post_url=post_url,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ class PeriodMetrics:
|
|||||||
def _calculate_metrics(
|
def _calculate_metrics(
|
||||||
period_metrics: PeriodMetrics,
|
period_metrics: PeriodMetrics,
|
||||||
requested_metrics: list[dto.ProjectMetrics] | None,
|
requested_metrics: list[dto.ProjectMetrics] | None,
|
||||||
|
hide_subscriptions: bool = False,
|
||||||
) -> dto.ProjectMetricsData:
|
) -> dto.ProjectMetricsData:
|
||||||
all_metrics = requested_metrics is None or len(requested_metrics) == 0
|
all_metrics = requested_metrics is None or len(requested_metrics) == 0
|
||||||
|
|
||||||
@@ -116,13 +117,13 @@ def _calculate_metrics(
|
|||||||
metrics.purchases_count = period_metrics.purchases_count
|
metrics.purchases_count = period_metrics.purchases_count
|
||||||
|
|
||||||
if should_include(dto.ProjectMetrics.TOTAL_SUBSCRIPTIONS):
|
if should_include(dto.ProjectMetrics.TOTAL_SUBSCRIPTIONS):
|
||||||
metrics.total_subscriptions = period_metrics.total_subscriptions
|
metrics.total_subscriptions = 0 if hide_subscriptions else period_metrics.total_subscriptions
|
||||||
|
|
||||||
if should_include(dto.ProjectMetrics.TOTAL_VIEWS):
|
if should_include(dto.ProjectMetrics.TOTAL_VIEWS):
|
||||||
metrics.total_views = period_metrics.total_views
|
metrics.total_views = period_metrics.total_views
|
||||||
|
|
||||||
if should_include(dto.ProjectMetrics.CLICKS_COUNT):
|
if should_include(dto.ProjectMetrics.CLICKS_COUNT):
|
||||||
metrics.clicks_count = period_metrics.clicks_count
|
metrics.clicks_count = 0 if hide_subscriptions else period_metrics.clicks_count
|
||||||
|
|
||||||
if should_include(dto.ProjectMetrics.REACH_VOLUME):
|
if should_include(dto.ProjectMetrics.REACH_VOLUME):
|
||||||
metrics.reach_volume = period_metrics.reach_volume
|
metrics.reach_volume = period_metrics.reach_volume
|
||||||
@@ -132,6 +133,9 @@ def _calculate_metrics(
|
|||||||
|
|
||||||
# Средние значения
|
# Средние значения
|
||||||
if should_include(dto.ProjectMetrics.AVG_CPF):
|
if should_include(dto.ProjectMetrics.AVG_CPF):
|
||||||
|
if hide_subscriptions:
|
||||||
|
metrics.avg_cpf = None
|
||||||
|
else:
|
||||||
metrics.avg_cpf = (
|
metrics.avg_cpf = (
|
||||||
period_metrics.total_cost / period_metrics.total_subscriptions
|
period_metrics.total_cost / period_metrics.total_subscriptions
|
||||||
if period_metrics.total_subscriptions > 0 and period_metrics.total_cost > 0
|
if period_metrics.total_subscriptions > 0 and period_metrics.total_cost > 0
|
||||||
@@ -160,6 +164,9 @@ def _calculate_metrics(
|
|||||||
|
|
||||||
if should_include(dto.ProjectMetrics.AVG_CONVERSION):
|
if should_include(dto.ProjectMetrics.AVG_CONVERSION):
|
||||||
# Конверсия = подписки / просмотры * 100
|
# Конверсия = подписки / просмотры * 100
|
||||||
|
if hide_subscriptions:
|
||||||
|
metrics.avg_conversion = None
|
||||||
|
else:
|
||||||
metrics.avg_conversion = (
|
metrics.avg_conversion = (
|
||||||
(period_metrics.total_subscriptions / period_metrics.total_views) * 100
|
(period_metrics.total_subscriptions / period_metrics.total_views) * 100
|
||||||
if period_metrics.total_views > 0 and period_metrics.total_subscriptions > 0
|
if period_metrics.total_views > 0 and period_metrics.total_subscriptions > 0
|
||||||
@@ -175,11 +182,10 @@ async def get_projects_analytics(
|
|||||||
if input.date_from and input.date_to and input.date_from > input.date_to:
|
if input.date_from and input.date_to and input.date_from > input.date_to:
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'date_from must be before date_to')
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'date_from must be before date_to')
|
||||||
|
|
||||||
context = await self.ensure_workspace_permission(
|
context = await self.ensure_analytics_permission(input.workspace_id, input.user_id)
|
||||||
input.workspace_id, input.user_id, domain.PermissionKey.ANALYTICS_READ
|
|
||||||
)
|
|
||||||
|
|
||||||
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ)
|
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ)
|
||||||
|
hide_subscriptions = context.should_hide_subscriptions()
|
||||||
|
|
||||||
# Фильтрация по project_ids если указаны
|
# Фильтрация по project_ids если указаны
|
||||||
if input.project_ids:
|
if input.project_ids:
|
||||||
@@ -293,7 +299,7 @@ async def get_projects_analytics(
|
|||||||
period_dt = datetime.datetime.now()
|
period_dt = datetime.datetime.now()
|
||||||
|
|
||||||
period_label = _format_period_label(period_dt, input.grouping)
|
period_label = _format_period_label(period_dt, input.grouping)
|
||||||
metrics = _calculate_metrics(pd, input.metrics)
|
metrics = _calculate_metrics(pd, input.metrics, hide_subscriptions)
|
||||||
|
|
||||||
periods.append(
|
periods.append(
|
||||||
dto.ProjectAnalyticsPeriod(
|
dto.ProjectAnalyticsPeriod(
|
||||||
@@ -303,6 +309,6 @@ async def get_projects_analytics(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
totals = _calculate_metrics(total_metrics, input.metrics)
|
totals = _calculate_metrics(total_metrics, input.metrics, hide_subscriptions)
|
||||||
|
|
||||||
return dto.GetProjectsAnalyticsOutput(periods=periods, totals=totals)
|
return dto.GetProjectsAnalyticsOutput(periods=periods, totals=totals)
|
||||||
|
|||||||
@@ -47,17 +47,15 @@ def _get_cost(placement_post: domain.PlacementPost) -> float | None:
|
|||||||
async def get_spending_analytics(
|
async def get_spending_analytics(
|
||||||
self: 'Usecase', input: dto.GetSpendingAnalyticsInput
|
self: 'Usecase', input: dto.GetSpendingAnalyticsInput
|
||||||
) -> dto.GetSpendingAnalyticsOutput:
|
) -> dto.GetSpendingAnalyticsOutput:
|
||||||
context = await self.ensure_workspace_permission(
|
context = await self.ensure_analytics_permission(input.workspace_id, input.user_id)
|
||||||
input.workspace_id, input.user_id, domain.PermissionKey.ANALYTICS_READ
|
|
||||||
)
|
|
||||||
|
|
||||||
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ)
|
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ)
|
||||||
|
hide_subscriptions = context.should_hide_subscriptions()
|
||||||
|
|
||||||
if input.project_id:
|
if input.project_id:
|
||||||
project = await self.database.get_project(input.workspace_id, input.project_id)
|
project = await self.database.get_project(input.workspace_id, input.project_id)
|
||||||
if not project:
|
if not project:
|
||||||
raise domain.ProjectNotFound(input.project_id)
|
raise domain.ProjectNotFound(input.project_id)
|
||||||
context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id)
|
|
||||||
allowed_project_ids = None
|
allowed_project_ids = None
|
||||||
|
|
||||||
placements = await self.database.get_workspace_placement_posts(
|
placements = await self.database.get_workspace_placement_posts(
|
||||||
@@ -116,7 +114,8 @@ async def get_spending_analytics(
|
|||||||
total_views += views_count
|
total_views += views_count
|
||||||
pd.views += views_count
|
pd.views += views_count
|
||||||
|
|
||||||
avg_cpf = total_cost / total_subs if total_subs > 0 and total_cost > 0 else None
|
output_total_subs = 0 if hide_subscriptions else total_subs
|
||||||
|
avg_cpf = None if hide_subscriptions else (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
|
# Count unique placements
|
||||||
@@ -132,10 +131,10 @@ async def get_spending_analytics(
|
|||||||
d = period_data[period]
|
d = period_data[period]
|
||||||
|
|
||||||
cost = d.cost
|
cost = d.cost
|
||||||
subs = d.subscriptions
|
subs = 0 if hide_subscriptions else d.subscriptions
|
||||||
views = d.views
|
views = d.views
|
||||||
|
|
||||||
cpf = cost / subs if subs > 0 and cost > 0 else None
|
cpf = None if hide_subscriptions else (cost / d.subscriptions if d.subscriptions > 0 and cost > 0 else None)
|
||||||
cpm = (cost / views * 1000) if views > 0 and cost > 0 else None
|
cpm = (cost / views * 1000) if views > 0 and cost > 0 else None
|
||||||
|
|
||||||
chart_data.append(
|
chart_data.append(
|
||||||
@@ -151,7 +150,7 @@ async def get_spending_analytics(
|
|||||||
|
|
||||||
return dto.GetSpendingAnalyticsOutput(
|
return dto.GetSpendingAnalyticsOutput(
|
||||||
total_cost=total_cost,
|
total_cost=total_cost,
|
||||||
total_subscriptions=total_subs,
|
total_subscriptions=output_total_subs,
|
||||||
total_views=total_views,
|
total_views=total_views,
|
||||||
avg_cpf=avg_cpf,
|
avg_cpf=avg_cpf,
|
||||||
avg_cpm=avg_cpm,
|
avg_cpm=avg_cpm,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ async def create_creative(
|
|||||||
self: 'Usecase', input: dto.CreateCreativeInput, project_id: uuid.UUID, user_id: uuid.UUID, workspace_id: uuid.UUID
|
self: 'Usecase', input: dto.CreateCreativeInput, project_id: uuid.UUID, user_id: uuid.UUID, workspace_id: uuid.UUID
|
||||||
) -> dto.CreativeOutput:
|
) -> dto.CreativeOutput:
|
||||||
await self.ensure_workspace_permission(
|
await self.ensure_workspace_permission(
|
||||||
workspace_id, user_id, domain.PermissionKey.PROJECTS_WRITE, for_project_id=project_id
|
workspace_id, user_id, domain.PermissionKey.CREATIVES_WRITE, for_project_id=project_id
|
||||||
)
|
)
|
||||||
|
|
||||||
project = await self.database.get_project(workspace_id, project_id=project_id)
|
project = await self.database.get_project(workspace_id, project_id=project_id)
|
||||||
@@ -34,6 +34,7 @@ async def create_creative(
|
|||||||
buttons=[button.model_dump() for button in input.buttons],
|
buttons=[button.model_dump() for button in input.buttons],
|
||||||
status=domain.CreativeStatus.ACTIVE,
|
status=domain.CreativeStatus.ACTIVE,
|
||||||
project_id=project.id,
|
project_id=project.id,
|
||||||
|
created_by_user_id=user_id,
|
||||||
)
|
)
|
||||||
await self.database.create_creative(creative)
|
await self.database.create_creative(creative)
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
async def delete_creative(self: 'Usecase', input: dto.DeleteCreativeInput) -> None:
|
async def delete_creative(self: 'Usecase', input: dto.DeleteCreativeInput) -> None:
|
||||||
context = await self.ensure_workspace_permission(
|
context = await self.ensure_workspace_permission(
|
||||||
input.workspace_id, input.user_id, domain.PermissionKey.PROJECTS_WRITE
|
input.workspace_id, input.user_id, domain.PermissionKey.CREATIVES_WRITE
|
||||||
)
|
)
|
||||||
|
|
||||||
creative = await self.database.get_creative(input.workspace_id, input.creative_id)
|
creative = await self.database.get_creative(input.workspace_id, input.creative_id)
|
||||||
@@ -20,7 +20,7 @@ async def delete_creative(self: 'Usecase', input: dto.DeleteCreativeInput) -> No
|
|||||||
log.warning('User %s attempted to delete unavailable creative %s', input.user_id, input.creative_id)
|
log.warning('User %s attempted to delete unavailable creative %s', input.user_id, input.creative_id)
|
||||||
raise domain.CreativeNotFound(input.creative_id)
|
raise domain.CreativeNotFound(input.creative_id)
|
||||||
|
|
||||||
context.ensure_project_permission(domain.PermissionKey.PROJECTS_WRITE, creative.project_id)
|
context.ensure_project_permission(domain.PermissionKey.CREATIVES_WRITE, creative.project_id)
|
||||||
|
|
||||||
has_placement_posts = await self.database.has_placement_posts_for_creative(creative.id)
|
has_placement_posts = await self.database.has_placement_posts_for_creative(creative.id)
|
||||||
if has_placement_posts:
|
if has_placement_posts:
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
async def get_creative(self: 'Usecase', input: dto.GetCreativeInput) -> dto.CreativeOutput:
|
async def get_creative(self: 'Usecase', input: dto.GetCreativeInput) -> dto.CreativeOutput:
|
||||||
context = await self.ensure_workspace_permission(
|
context = await self.ensure_workspace_permission(
|
||||||
input.workspace_id, input.user_id, domain.PermissionKey.PROJECTS_READ
|
input.workspace_id, input.user_id, domain.PermissionKey.CREATIVES_READ
|
||||||
)
|
)
|
||||||
|
|
||||||
creative = await self.database.get_creative(input.workspace_id, input.creative_id)
|
creative = await self.database.get_creative(input.workspace_id, input.creative_id)
|
||||||
if not creative:
|
if not creative:
|
||||||
raise domain.CreativeNotFound(input.creative_id)
|
raise domain.CreativeNotFound(input.creative_id)
|
||||||
|
|
||||||
context.ensure_project_permission(domain.PermissionKey.PROJECTS_READ, creative.project_id)
|
context.ensure_project_permission(domain.PermissionKey.CREATIVES_READ, creative.project_id)
|
||||||
|
|
||||||
placements_count = await self.database.count_placement_posts_by_creative(creative.id)
|
placements_count = await self.database.count_placement_posts_by_creative(creative.id)
|
||||||
media_rel = creative.media_items
|
media_rel = creative.media_items
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> list[dto.CreativeOutput]:
|
async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> list[dto.CreativeOutput]:
|
||||||
context = await self.ensure_workspace_permission(
|
context = await self.ensure_workspace_permission(
|
||||||
input.workspace_id, input.user_id, domain.PermissionKey.PROJECTS_READ
|
input.workspace_id, input.user_id, domain.PermissionKey.CREATIVES_READ
|
||||||
)
|
)
|
||||||
|
|
||||||
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.PROJECTS_READ)
|
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.CREATIVES_READ)
|
||||||
|
|
||||||
if input.project_id is not None:
|
if input.project_id is not None:
|
||||||
context.ensure_project_permission(domain.PermissionKey.PROJECTS_READ, input.project_id)
|
context.ensure_project_permission(domain.PermissionKey.CREATIVES_READ, input.project_id)
|
||||||
allowed_project_ids = None
|
allowed_project_ids = None
|
||||||
|
|
||||||
creatives = await self.database.get_workspace_creatives(
|
creatives = await self.database.get_workspace_creatives(
|
||||||
|
|||||||
@@ -18,14 +18,14 @@ async def update_creative(
|
|||||||
user_id: uuid.UUID,
|
user_id: uuid.UUID,
|
||||||
workspace_id: uuid.UUID,
|
workspace_id: uuid.UUID,
|
||||||
) -> dto.CreativeOutput:
|
) -> dto.CreativeOutput:
|
||||||
context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PROJECTS_WRITE)
|
context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.CREATIVES_WRITE)
|
||||||
|
|
||||||
creative = await self.database.get_creative(workspace_id, creative_id)
|
creative = await self.database.get_creative(workspace_id, creative_id)
|
||||||
if not creative:
|
if not creative:
|
||||||
log.warning('User %s attempted to update unavailable creative %s', user_id, creative_id)
|
log.warning('User %s attempted to update unavailable creative %s', user_id, creative_id)
|
||||||
raise domain.CreativeNotFound(creative_id)
|
raise domain.CreativeNotFound(creative_id)
|
||||||
|
|
||||||
context.ensure_project_permission(domain.PermissionKey.PROJECTS_WRITE, creative.project_id)
|
context.ensure_project_permission(domain.PermissionKey.CREATIVES_WRITE, creative.project_id)
|
||||||
|
|
||||||
if input.name:
|
if input.name:
|
||||||
creative.name = input.name
|
creative.name = input.name
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ type ChannelConfig struct {
|
|||||||
ChannelDelay time.Duration `envconfig:"WORKER__CHANNEL_DELAY" default:"500ms"`
|
ChannelDelay time.Duration `envconfig:"WORKER__CHANNEL_DELAY" default:"500ms"`
|
||||||
RequestTimeout time.Duration `envconfig:"WORKER__REQUEST_TIMEOUT" default:"10s"`
|
RequestTimeout time.Duration `envconfig:"WORKER__REQUEST_TIMEOUT" default:"10s"`
|
||||||
MessagesLimit int `envconfig:"WORKER__MESSAGES_LIMIT" default:"20"`
|
MessagesLimit int `envconfig:"WORKER__MESSAGES_LIMIT" default:"20"`
|
||||||
PollInterval time.Duration `envconfig:"WORKER__POLL_INTERVAL" default:"20s"`
|
PollInterval time.Duration `envconfig:"WORKER__POLL_INTERVAL" default:"120s"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChannelWorker struct {
|
type ChannelWorker struct {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ViewsConfig struct {
|
type ViewsConfig struct {
|
||||||
Interval time.Duration `envconfig:"WORKER__VIEWS_INTERVAL" default:"30s"`
|
Interval time.Duration `envconfig:"WORKER__VIEWS_INTERVAL" default:"1800s"`
|
||||||
RequestTimeout time.Duration `envconfig:"WORKER__REQUEST_TIMEOUT" default:"10s"`
|
RequestTimeout time.Duration `envconfig:"WORKER__REQUEST_TIMEOUT" default:"10s"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user