права фикс

This commit is contained in:
Artem Tsyrulnikov
2026-01-27 19:29:10 +03:00
parent b5d839ddae
commit 2d35588a85
21 changed files with 309 additions and 115 deletions

View File

@@ -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)

View File

@@ -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'

View File

@@ -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'

View File

@@ -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:

View File

@@ -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(

View File

@@ -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=['*'],

View File

@@ -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:

View File

@@ -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,

View File

@@ -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,

View File

@@ -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(

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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,

View File

@@ -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)

View File

@@ -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:

View File

@@ -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

View File

@@ -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(

View File

@@ -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