права фикс

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"`
} }