права фикс
This commit is contained in:
@@ -361,6 +361,7 @@ class Postgres(DatabaseBase):
|
||||
project_id: uuid.UUID | None = None,
|
||||
include_archived: bool = False,
|
||||
allowed_project_ids: set[uuid.UUID] | None = None,
|
||||
created_by_user_id: uuid.UUID | None = None,
|
||||
) -> list[domain.Creative]:
|
||||
query = domain.Creative.filter(project__workspace_id=workspace_id)
|
||||
|
||||
@@ -371,6 +372,9 @@ class Postgres(DatabaseBase):
|
||||
return []
|
||||
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:
|
||||
query = query.filter(status=domain.CreativeStatus.ACTIVE)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from .error import (
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .project import Project
|
||||
from .user import User
|
||||
|
||||
|
||||
class CreativeStatus(str, enum.Enum):
|
||||
@@ -32,9 +33,13 @@ class Creative(TimestampedModel):
|
||||
project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
|
||||
'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:
|
||||
project_id: UUID
|
||||
created_by_user_id: UUID | None
|
||||
|
||||
class Meta:
|
||||
table = 'creative'
|
||||
|
||||
@@ -10,7 +10,6 @@ from .base import TimestampedModel
|
||||
from .error import WorkspaceAvatarTooLarge
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .channel import Channel
|
||||
from .creative import Creative
|
||||
from .placement import Placement
|
||||
from .project import Project
|
||||
@@ -94,16 +93,28 @@ class PermissionKey(enum.StrEnum):
|
||||
|
||||
ANALYTICS_READ = 'analytics_read'
|
||||
ANALYTICS_WITHOUT_CLICKS = 'analytics_without_clicks'
|
||||
ANALYTICS_OWN_CREATIVES = 'analytics_own_creatives'
|
||||
|
||||
CHANNELS_READ = 'channels_read'
|
||||
CHANNELS_WRITE = 'channels_write'
|
||||
@property
|
||||
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):
|
||||
PROJECT = 'project'
|
||||
CREATIVE = 'creative'
|
||||
PLACEMENT = 'placement'
|
||||
CHANNEL = 'channel'
|
||||
|
||||
|
||||
class WorkspaceUserPermission(TimestampedModel):
|
||||
@@ -138,16 +149,12 @@ class WorkspaceUserPermissionScope(TimestampedModel):
|
||||
placement: fields.ForeignKeyRelation[Placement] | None = fields.ForeignKeyField(
|
||||
'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:
|
||||
workspace_user_id: uuid.UUID
|
||||
project_id: uuid.UUID | None
|
||||
creative_id: uuid.UUID | None
|
||||
placement_id: uuid.UUID | None
|
||||
channel_id: uuid.UUID | None
|
||||
|
||||
class Meta:
|
||||
table = 'workspace_user_permission_scope'
|
||||
|
||||
@@ -32,9 +32,6 @@ class WorkspacePermissions:
|
||||
elif scope.placement_id:
|
||||
key = (scope.permission, PermissionScopeType.PLACEMENT)
|
||||
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)
|
||||
|
||||
@@ -77,18 +74,6 @@ class WorkspacePermissions:
|
||||
|
||||
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(
|
||||
self,
|
||||
permission: PermissionKey,
|
||||
@@ -106,8 +91,6 @@ class WorkspacePermissions:
|
||||
allowed = self.allowed_creative_ids(permission)
|
||||
elif scope_type == PermissionScopeType.PLACEMENT:
|
||||
allowed = self.allowed_placement_ids(permission)
|
||||
elif scope_type == PermissionScopeType.CHANNEL:
|
||||
allowed = self.allowed_channel_ids(permission)
|
||||
else:
|
||||
return False
|
||||
|
||||
@@ -123,6 +106,28 @@ class WorkspacePermissions:
|
||||
return True
|
||||
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
|
||||
class WorkspacePermissionContext:
|
||||
@@ -139,9 +144,6 @@ class WorkspacePermissionContext:
|
||||
def allowed_placement_ids(self, permission: PermissionKey) -> set[UUID] | None:
|
||||
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:
|
||||
if not self.permissions.has_permission(
|
||||
permission,
|
||||
@@ -166,13 +168,13 @@ class WorkspacePermissionContext:
|
||||
):
|
||||
raise WorkspaceAccessDenied(self.workspace.id)
|
||||
|
||||
def ensure_channel_permission(self, permission: PermissionKey, channel_id: UUID) -> None:
|
||||
if not self.permissions.has_permission(
|
||||
permission,
|
||||
scope_type=PermissionScopeType.CHANNEL,
|
||||
scope_id=channel_id,
|
||||
):
|
||||
raise WorkspaceAccessDenied(self.workspace.id)
|
||||
def should_hide_subscriptions(self) -> bool:
|
||||
"""Check if subscription data should be hidden."""
|
||||
return self.permissions.should_hide_subscriptions()
|
||||
|
||||
def should_filter_own_creatives(self) -> bool:
|
||||
"""Check if analytics should be filtered to user's own creatives only."""
|
||||
return self.permissions.should_filter_own_creatives()
|
||||
|
||||
|
||||
def build_workspace_permission_context(membership: WorkspaceUser) -> WorkspacePermissionContext:
|
||||
|
||||
@@ -74,9 +74,6 @@ class WorkspaceMemberOutput(pydantic.BaseModel):
|
||||
elif scope.placement_id:
|
||||
scope_type = domain.PermissionScopeType.PLACEMENT
|
||||
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:
|
||||
permissions_map.setdefault(scope.permission, []).append(
|
||||
@@ -112,8 +109,8 @@ class WorkspacePermissionScopeInput(pydantic.BaseModel):
|
||||
class WorkspacePermissionInput(pydantic.BaseModel):
|
||||
key: domain.PermissionKey = pydantic.Field(
|
||||
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(
|
||||
|
||||
@@ -66,6 +66,7 @@ app.add_middleware(
|
||||
allow_origins=[
|
||||
'https://app.tgexchange.ru',
|
||||
'http://app.tgexchange.ru',
|
||||
'http://localhost:3000',
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=['*'],
|
||||
|
||||
@@ -164,6 +164,21 @@ class Usecase:
|
||||
|
||||
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:
|
||||
workspace = await self.database.get_default_workspace_for_user(user.id)
|
||||
if workspace:
|
||||
|
||||
@@ -19,17 +19,15 @@ def _get_cost(placement_post: domain.PlacementPost) -> float:
|
||||
async def get_channel_analytics(
|
||||
self: 'Usecase', input: dto.GetChannelAnalyticsInput
|
||||
) -> list[dto.ChannelAnalyticsOutput]:
|
||||
context = await self.ensure_workspace_permission(
|
||||
input.workspace_id, input.user_id, domain.PermissionKey.ANALYTICS_READ
|
||||
)
|
||||
context = await self.ensure_analytics_permission(input.workspace_id, input.user_id)
|
||||
|
||||
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ)
|
||||
hide_subscriptions = context.should_hide_subscriptions()
|
||||
|
||||
if input.project_id:
|
||||
project = await self.database.get_project(input.workspace_id, input.project_id)
|
||||
if not project:
|
||||
raise domain.ProjectNotFound(input.project_id)
|
||||
context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id)
|
||||
allowed_project_filter = None
|
||||
else:
|
||||
allowed_project_filter = allowed_project_ids
|
||||
@@ -88,11 +86,10 @@ async def get_channel_analytics(
|
||||
for ch in channels:
|
||||
stats = channel_stats[ch.id]
|
||||
|
||||
avg_cpf = (
|
||||
(stats.total_cost / stats.total_subscriptions)
|
||||
if stats.total_subscriptions > 0 and stats.total_cost > 0
|
||||
else None
|
||||
)
|
||||
total_subscriptions = 0 if hide_subscriptions else stats.total_subscriptions
|
||||
avg_cpf = None
|
||||
if not hide_subscriptions and stats.total_subscriptions > 0 and stats.total_cost > 0:
|
||||
avg_cpf = stats.total_cost / stats.total_subscriptions
|
||||
avg_cpm = (
|
||||
(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,
|
||||
placements_count=stats.placements_count,
|
||||
total_cost=stats.total_cost,
|
||||
total_subscriptions=stats.total_subscriptions,
|
||||
total_subscriptions=total_subscriptions,
|
||||
total_views=stats.total_views,
|
||||
avg_cpf=avg_cpf,
|
||||
avg_cpm=avg_cpm,
|
||||
|
||||
@@ -19,17 +19,21 @@ def _get_cost(placement_post: domain.PlacementPost) -> float:
|
||||
async def get_creatives_analytics(
|
||||
self: 'Usecase', input: dto.GetCreativesAnalyticsInput
|
||||
) -> list[dto.CreativeAnalyticsOutput]:
|
||||
context = await self.ensure_workspace_permission(
|
||||
input.workspace_id, input.user_id, domain.PermissionKey.ANALYTICS_READ
|
||||
)
|
||||
context = await self.ensure_analytics_permission(input.workspace_id, input.user_id)
|
||||
|
||||
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:
|
||||
project = await self.database.get_project(input.workspace_id, input.project_id)
|
||||
if not project:
|
||||
raise domain.ProjectNotFound(input.project_id)
|
||||
context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id)
|
||||
allowed_project_ids = None
|
||||
|
||||
creatives = await self.database.get_workspace_creatives(
|
||||
@@ -37,6 +41,7 @@ async def get_creatives_analytics(
|
||||
input.project_id,
|
||||
include_archived=False,
|
||||
allowed_project_ids=allowed_project_ids,
|
||||
created_by_user_id=created_by_filter,
|
||||
)
|
||||
placements = await self.database.get_workspace_placement_posts(
|
||||
input.workspace_id,
|
||||
@@ -84,11 +89,10 @@ async def get_creatives_analytics(
|
||||
for cr in creatives:
|
||||
stats = creative_stats[cr.id]
|
||||
|
||||
avg_cpf = (
|
||||
(stats.total_cost / stats.total_subscriptions)
|
||||
if stats.total_subscriptions > 0 and stats.total_cost > 0
|
||||
else None
|
||||
)
|
||||
total_subscriptions = 0 if hide_subscriptions else stats.total_subscriptions
|
||||
avg_cpf = None
|
||||
if not hide_subscriptions and stats.total_subscriptions > 0 and stats.total_cost > 0:
|
||||
avg_cpf = stats.total_cost / stats.total_subscriptions
|
||||
avg_cpm = (
|
||||
(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,
|
||||
placements_count=stats.placements_count,
|
||||
total_cost=stats.total_cost,
|
||||
total_subscriptions=stats.total_subscriptions,
|
||||
total_subscriptions=total_subscriptions,
|
||||
total_views=stats.total_views,
|
||||
avg_cpf=avg_cpf,
|
||||
avg_cpm=avg_cpm,
|
||||
|
||||
@@ -49,17 +49,15 @@ async def get_overview_analytics(
|
||||
if input.date_from > input.date_to:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'date_from must be before date_to')
|
||||
|
||||
context = await self.ensure_workspace_permission(
|
||||
input.workspace_id, input.user_id, domain.PermissionKey.ANALYTICS_READ
|
||||
)
|
||||
context = await self.ensure_analytics_permission(input.workspace_id, input.user_id)
|
||||
|
||||
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ)
|
||||
hide_subscriptions = context.should_hide_subscriptions()
|
||||
|
||||
if input.project_id:
|
||||
project = await self.database.get_project(input.workspace_id, input.project_id)
|
||||
if not project:
|
||||
raise domain.ProjectNotFound(input.project_id)
|
||||
context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id)
|
||||
allowed_project_ids = None
|
||||
|
||||
raw_duration = input.date_to - input.date_from
|
||||
@@ -132,8 +130,15 @@ async def get_overview_analytics(
|
||||
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)
|
||||
|
||||
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
|
||||
# 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
|
||||
previous_avg_cpf = previous_cost / previous_subs if previous_cost > 0 and previous_subs > 0 else None
|
||||
|
||||
current_avg_cpm = (current_cost / current_views * 1000) if current_cost > 0 and current_views > 0 else None
|
||||
previous_avg_cpm = (previous_cost / previous_views * 1000) if previous_cost > 0 and previous_views > 0 else None
|
||||
@@ -171,9 +176,9 @@ async def get_overview_analytics(
|
||||
for day_offset in range(days_span + 1):
|
||||
day = start_date + datetime.timedelta(days=day_offset)
|
||||
cost = cost_per_day.get(day, 0.0)
|
||||
subs = subs_per_day_current.get(day, 0)
|
||||
delta = (subs - previous_day_subs) if previous_day_subs is not None else subs
|
||||
cpf = cost / subs if subs > 0 and cost > 0 else None
|
||||
subs = 0 if hide_subscriptions else subs_per_day_current.get(day, 0)
|
||||
delta = None if hide_subscriptions else ((subs - previous_day_subs) if previous_day_subs is not None else subs)
|
||||
cpf = None if hide_subscriptions else (cost / subs if subs > 0 and cost > 0 else None)
|
||||
daily_stats.append(
|
||||
dto.OverviewDailyPoint(date=day, cost=cost, subscriptions=subs, subscriptions_delta=delta, cpf=cpf)
|
||||
)
|
||||
@@ -181,12 +186,15 @@ async def get_overview_analytics(
|
||||
|
||||
channel_performance = []
|
||||
for stats in channel_stats.values():
|
||||
subs = stats.total_subs
|
||||
subs = 0 if hide_subscriptions else stats.total_subs
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
channel_performance.append(
|
||||
|
||||
@@ -39,17 +39,15 @@ def _calculate_cpm(cost: float | None, views: int | None) -> float | None:
|
||||
async def get_placements_analytics(
|
||||
self: 'Usecase', input: dto.GetPlacementsAnalyticsInput
|
||||
) -> list[dto.PlacementAnalyticsOutput]:
|
||||
context = await self.ensure_workspace_permission(
|
||||
input.workspace_id, input.user_id, domain.PermissionKey.ANALYTICS_READ
|
||||
)
|
||||
context = await self.ensure_analytics_permission(input.workspace_id, input.user_id)
|
||||
|
||||
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ)
|
||||
hide_subscriptions = context.should_hide_subscriptions()
|
||||
|
||||
if input.project_id:
|
||||
project = await self.database.get_project(input.workspace_id, input.project_id)
|
||||
if not project:
|
||||
raise domain.ProjectNotFound(input.project_id)
|
||||
context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id)
|
||||
allowed_project_ids = None
|
||||
|
||||
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}'
|
||||
|
||||
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:
|
||||
views_count = views_map[placement_post.post.id][0]
|
||||
else:
|
||||
views_count = None
|
||||
cpf = None if hide_subscriptions else _calculate_cpf(cost, subs_count)
|
||||
results.append(
|
||||
dto.PlacementAnalyticsOutput(
|
||||
id=placement_post.id,
|
||||
@@ -111,7 +110,7 @@ async def get_placements_analytics(
|
||||
cost=cost,
|
||||
subscriptions_count=subs_count,
|
||||
views_count=views_count,
|
||||
cpf=_calculate_cpf(cost, subs_count),
|
||||
cpf=cpf,
|
||||
cpm=_calculate_cpm(cost, views_count),
|
||||
post_url=post_url,
|
||||
)
|
||||
|
||||
@@ -101,6 +101,7 @@ class PeriodMetrics:
|
||||
def _calculate_metrics(
|
||||
period_metrics: PeriodMetrics,
|
||||
requested_metrics: list[dto.ProjectMetrics] | None,
|
||||
hide_subscriptions: bool = False,
|
||||
) -> dto.ProjectMetricsData:
|
||||
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
|
||||
|
||||
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):
|
||||
metrics.total_views = period_metrics.total_views
|
||||
|
||||
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):
|
||||
metrics.reach_volume = period_metrics.reach_volume
|
||||
@@ -132,11 +133,14 @@ def _calculate_metrics(
|
||||
|
||||
# Средние значения
|
||||
if should_include(dto.ProjectMetrics.AVG_CPF):
|
||||
metrics.avg_cpf = (
|
||||
period_metrics.total_cost / period_metrics.total_subscriptions
|
||||
if period_metrics.total_subscriptions > 0 and period_metrics.total_cost > 0
|
||||
else None
|
||||
)
|
||||
if hide_subscriptions:
|
||||
metrics.avg_cpf = None
|
||||
else:
|
||||
metrics.avg_cpf = (
|
||||
period_metrics.total_cost / period_metrics.total_subscriptions
|
||||
if period_metrics.total_subscriptions > 0 and period_metrics.total_cost > 0
|
||||
else None
|
||||
)
|
||||
|
||||
if should_include(dto.ProjectMetrics.AVG_CPM):
|
||||
metrics.avg_cpm = (
|
||||
@@ -160,11 +164,14 @@ def _calculate_metrics(
|
||||
|
||||
if should_include(dto.ProjectMetrics.AVG_CONVERSION):
|
||||
# Конверсия = подписки / просмотры * 100
|
||||
metrics.avg_conversion = (
|
||||
(period_metrics.total_subscriptions / period_metrics.total_views) * 100
|
||||
if period_metrics.total_views > 0 and period_metrics.total_subscriptions > 0
|
||||
else 0.0
|
||||
)
|
||||
if hide_subscriptions:
|
||||
metrics.avg_conversion = None
|
||||
else:
|
||||
metrics.avg_conversion = (
|
||||
(period_metrics.total_subscriptions / period_metrics.total_views) * 100
|
||||
if period_metrics.total_views > 0 and period_metrics.total_subscriptions > 0
|
||||
else 0.0
|
||||
)
|
||||
|
||||
return metrics
|
||||
|
||||
@@ -175,11 +182,10 @@ async def get_projects_analytics(
|
||||
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')
|
||||
|
||||
context = await self.ensure_workspace_permission(
|
||||
input.workspace_id, input.user_id, domain.PermissionKey.ANALYTICS_READ
|
||||
)
|
||||
context = await self.ensure_analytics_permission(input.workspace_id, input.user_id)
|
||||
|
||||
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ)
|
||||
hide_subscriptions = context.should_hide_subscriptions()
|
||||
|
||||
# Фильтрация по project_ids если указаны
|
||||
if input.project_ids:
|
||||
@@ -293,7 +299,7 @@ async def get_projects_analytics(
|
||||
period_dt = datetime.datetime.now()
|
||||
|
||||
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(
|
||||
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)
|
||||
|
||||
@@ -47,17 +47,15 @@ def _get_cost(placement_post: domain.PlacementPost) -> float | None:
|
||||
async def get_spending_analytics(
|
||||
self: 'Usecase', input: dto.GetSpendingAnalyticsInput
|
||||
) -> dto.GetSpendingAnalyticsOutput:
|
||||
context = await self.ensure_workspace_permission(
|
||||
input.workspace_id, input.user_id, domain.PermissionKey.ANALYTICS_READ
|
||||
)
|
||||
context = await self.ensure_analytics_permission(input.workspace_id, input.user_id)
|
||||
|
||||
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ)
|
||||
hide_subscriptions = context.should_hide_subscriptions()
|
||||
|
||||
if input.project_id:
|
||||
project = await self.database.get_project(input.workspace_id, input.project_id)
|
||||
if not project:
|
||||
raise domain.ProjectNotFound(input.project_id)
|
||||
context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id)
|
||||
allowed_project_ids = None
|
||||
|
||||
placements = await self.database.get_workspace_placement_posts(
|
||||
@@ -116,7 +114,8 @@ async def get_spending_analytics(
|
||||
total_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
|
||||
|
||||
# Count unique placements
|
||||
@@ -132,10 +131,10 @@ async def get_spending_analytics(
|
||||
d = period_data[period]
|
||||
|
||||
cost = d.cost
|
||||
subs = d.subscriptions
|
||||
subs = 0 if hide_subscriptions else d.subscriptions
|
||||
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
|
||||
|
||||
chart_data.append(
|
||||
@@ -151,7 +150,7 @@ async def get_spending_analytics(
|
||||
|
||||
return dto.GetSpendingAnalyticsOutput(
|
||||
total_cost=total_cost,
|
||||
total_subscriptions=total_subs,
|
||||
total_subscriptions=output_total_subs,
|
||||
total_views=total_views,
|
||||
avg_cpf=avg_cpf,
|
||||
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
|
||||
) -> dto.CreativeOutput:
|
||||
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)
|
||||
@@ -34,6 +34,7 @@ async def create_creative(
|
||||
buttons=[button.model_dump() for button in input.buttons],
|
||||
status=domain.CreativeStatus.ACTIVE,
|
||||
project_id=project.id,
|
||||
created_by_user_id=user_id,
|
||||
)
|
||||
await self.database.create_creative(creative)
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ log = logging.getLogger(__name__)
|
||||
|
||||
async def delete_creative(self: 'Usecase', input: dto.DeleteCreativeInput) -> None:
|
||||
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)
|
||||
@@ -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)
|
||||
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)
|
||||
if has_placement_posts:
|
||||
|
||||
@@ -11,14 +11,14 @@ log = logging.getLogger(__name__)
|
||||
|
||||
async def get_creative(self: 'Usecase', input: dto.GetCreativeInput) -> dto.CreativeOutput:
|
||||
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)
|
||||
if not creative:
|
||||
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)
|
||||
media_rel = creative.media_items
|
||||
|
||||
@@ -8,13 +8,13 @@ if TYPE_CHECKING:
|
||||
|
||||
async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> list[dto.CreativeOutput]:
|
||||
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:
|
||||
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
|
||||
|
||||
creatives = await self.database.get_workspace_creatives(
|
||||
|
||||
@@ -18,14 +18,14 @@ async def update_creative(
|
||||
user_id: uuid.UUID,
|
||||
workspace_id: uuid.UUID,
|
||||
) -> 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)
|
||||
if not creative:
|
||||
log.warning('User %s attempted to update unavailable creative %s', user_id, 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:
|
||||
creative.name = input.name
|
||||
|
||||
Reference in New Issue
Block a user