From 4705e88663ec55880b85d84fa3c2dc803236277b Mon Sep 17 00:00:00 2001 From: Artem Tsyrulnikov Date: Sun, 18 Jan 2026 13:08:04 +0300 Subject: [PATCH] =?UTF-8?q?refactor,=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9?= =?UTF-8?q?=D1=81=20=D0=91=D0=94=20=D0=B8=D0=B7-=D0=B7=D0=B0=20=D1=81?= =?UTF-8?q?=D0=BB=D0=B0=D0=B1=D0=BE=D0=B9=20=D0=BD=D0=B5=D0=BE=D0=B1=D1=85?= =?UTF-8?q?=D0=BE=D0=B4=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...actor_purchase_to_placement_publication.py | 52 +++- src/adapter/postgres.py | 276 +++++++++--------- src/adapter/telegram_bot.py | 6 - src/controller/http_v1/internal.py | 1 - src/dto/internal.py | 6 - src/dto/placement.py | 22 -- src/usecase/__init__.py | 251 +--------------- .../analytics/get_projects_analytics.py | 6 +- 8 files changed, 193 insertions(+), 427 deletions(-) delete mode 100644 src/dto/internal.py diff --git a/migrations/models/2_20260115142040_refactor_purchase_to_placement_publication.py b/migrations/models/2_20260115142040_refactor_purchase_to_placement_publication.py index 92b8cdd..dc41d49 100644 --- a/migrations/models/2_20260115142040_refactor_purchase_to_placement_publication.py +++ b/migrations/models/2_20260115142040_refactor_purchase_to_placement_publication.py @@ -83,9 +83,27 @@ async def upgrade(db: BaseDBAsyncClient) -> str: op.creative_id, op.purchase_channel_id, op.post_id, op.project_id FROM "old_placement" op; - -- Шаг 6: Обновить subscription (placement_id -> publication_id) + -- Шаг 6: Обновить foreign keys для таблиц, ссылающихся на old_placement + + -- 6.1: Обновить subscription (placement_id -> publication_id) + -- Удалить constraint ДО переименования колонки + ALTER TABLE "subscription" + DROP CONSTRAINT IF EXISTS "subscription_placement_id_fkey"; + + -- Переименовать колонку ALTER TABLE "subscription" RENAME COLUMN "placement_id" TO "publication_id"; + -- 6.2: Обновить workspace_user_permission_scope.placement_id + -- Удалить старый foreign key constraint ДО обновления данных + ALTER TABLE "workspace_user_permission_scope" + DROP CONSTRAINT IF EXISTS "workspace_user_permission_scope_placement_id_fkey"; + + -- Обновить данные: старые placement_id -> новые placement_id (через purchase_channel_id) + UPDATE "workspace_user_permission_scope" wups + SET placement_id = op.purchase_channel_id + FROM "old_placement" op + WHERE wups.placement_id = op.id; + -- Шаг 7: Создать индексы и constraints CREATE INDEX "idx_placement_project_4155ce" ON "placement" ("project_id", "status"); @@ -108,6 +126,11 @@ async def upgrade(db: BaseDBAsyncClient) -> str: CREATE UNIQUE INDEX "uid_subscriptio_publica_578bfb" ON "subscription" ("publication_id", "telegram_user_id"); + ALTER TABLE "workspace_user_permission_scope" + ADD CONSTRAINT "fk_workspac_placemen_8c5d3f2a" + FOREIGN KEY ("placement_id") + REFERENCES "placement" ("id") ON DELETE CASCADE; + -- Шаг 8: Добавить комментарии COMMENT ON TABLE "placement" IS 'Размещение, управляемое пользователем (бывший PurchaseChannel + Purchase)'; @@ -262,10 +285,28 @@ async def downgrade(db: BaseDBAsyncClient) -> str: pub.creative_id, pub.placement_id, pub.post_id, pub.project_id FROM "publication" pub; - -- Шаг 8: Обновить subscription (publication_id -> placement_id) + -- Шаг 8: Обновить foreign keys обратно + + -- 8.1: Обновить subscription (publication_id -> placement_id) + -- Удалить новый foreign key constraint + ALTER TABLE "subscription" + DROP CONSTRAINT IF EXISTS "fk_subscrip_publicat_1959be9e"; + + -- Переименовать колонку обратно ALTER TABLE "subscription" RENAME COLUMN "publication_id" TO "placement_id"; - -- Шаг 9: Создать индексы + -- 8.2: Обновить workspace_user_permission_scope.placement_id обратно + -- Удалить constraint на новый placement + ALTER TABLE "workspace_user_permission_scope" + DROP CONSTRAINT IF EXISTS "fk_workspac_placemen_8c5d3f2a"; + + -- Обновить данные обратно: новые placement_id -> старые placement_id + UPDATE "workspace_user_permission_scope" wups + SET placement_id = pub.id + FROM "publication" pub + WHERE wups.placement_id = pub.placement_id; + + -- Шаг 9: Создать индексы и constraints CREATE INDEX "idx_purchase_project_status" ON "purchase" ("project_id", "status"); CREATE INDEX "idx_placement_purchas_61f9e6" ON "placement" ("purchase_channel_id"); @@ -279,6 +320,11 @@ async def downgrade(db: BaseDBAsyncClient) -> str: CREATE UNIQUE INDEX "uid_subscriptio_placeme_75b40b" ON "subscription" ("placement_id", "telegram_user_id"); + ALTER TABLE "workspace_user_permission_scope" + ADD CONSTRAINT "fk_workspac_placemen_8c5d3f2a" + FOREIGN KEY ("placement_id") + REFERENCES "placement" ("id") ON DELETE CASCADE; + -- Шаг 10: Удалить новые таблицы DROP TABLE IF EXISTS "publication"; DROP TABLE IF EXISTS "new_placement"; diff --git a/src/adapter/postgres.py b/src/adapter/postgres.py index fe4c363..327fbe0 100644 --- a/src/adapter/postgres.py +++ b/src/adapter/postgres.py @@ -8,25 +8,22 @@ from tortoise.transactions import in_transaction from shared.datebase_base import DatabaseBase from src import domain -from src.usecase import Database log = logging.getLogger(__name__) -class Postgres(DatabaseBase, Database): - def transaction(self) -> typing.AsyncContextManager[None]: +class Postgres(DatabaseBase): + @staticmethod + def transaction() -> typing.AsyncContextManager[None]: return in_transaction() - async def create_user(self, user: domain.User) -> None: - await user.save() - - async def update_user(self, user: domain.User) -> None: + @staticmethod + async def create_user(user: domain.User) -> None: await user.save() + @staticmethod async def get_telegram_user( - self, - telegram_user_id: uuid.UUID | None = None, - telegram_id: int | None = None, + telegram_user_id: uuid.UUID | None = None, telegram_id: int | None = None ) -> domain.TelegramUser | None: if telegram_user_id: return await domain.TelegramUser.get_or_none(id=telegram_user_id) @@ -35,32 +32,40 @@ class Postgres(DatabaseBase, Database): raise ValueError('Either telegram_user_id or telegram_id must be provided') - async def create_telegram_user(self, telegram_user: domain.TelegramUser) -> None: + @staticmethod + async def create_telegram_user(telegram_user: domain.TelegramUser) -> None: await telegram_user.save() - async def update_telegram_user(self, telegram_user: domain.TelegramUser) -> None: + @staticmethod + async def update_telegram_user(telegram_user: domain.TelegramUser) -> None: await telegram_user.save() - async def create_workspace(self, workspace: domain.Workspace) -> None: + @staticmethod + async def create_workspace(workspace: domain.Workspace) -> None: await workspace.save() - async def update_workspace(self, workspace: domain.Workspace) -> None: + @staticmethod + async def update_workspace(workspace: domain.Workspace) -> None: await workspace.save() - async def delete_workspace(self, workspace_id: uuid.UUID) -> None: + @staticmethod + async def delete_workspace(workspace_id: uuid.UUID) -> None: await domain.Workspace.filter(id=workspace_id).delete() - async def add_user_to_workspace(self, workspace_id: uuid.UUID, user_id: uuid.UUID) -> domain.WorkspaceUser: + @staticmethod + async def add_user_to_workspace(workspace_id: uuid.UUID, user_id: uuid.UUID) -> domain.WorkspaceUser: return await domain.WorkspaceUser.create( workspace_id=workspace_id, user_id=user_id, status=domain.WorkspaceUserStatus.ACTIVE, ) - async def update_workspace_user(self, workspace_user: domain.WorkspaceUser) -> None: + @staticmethod + async def update_workspace_user(workspace_user: domain.WorkspaceUser) -> None: await workspace_user.save() - async def get_user_workspaces(self, user_id: uuid.UUID) -> list[domain.WorkspaceUser]: + @staticmethod + async def get_user_workspaces(user_id: uuid.UUID) -> list[domain.WorkspaceUser]: return ( await domain.WorkspaceUser.filter(user_id=user_id) .prefetch_related('workspace') @@ -68,10 +73,12 @@ class Postgres(DatabaseBase, Database): .all() ) - async def get_workspace(self, workspace_id: uuid.UUID) -> domain.Workspace | None: + @staticmethod + async def get_workspace(workspace_id: uuid.UUID) -> domain.Workspace | None: return await domain.Workspace.get_or_none(id=workspace_id) - async def get_workspace_for_user(self, workspace_id: uuid.UUID, user_id: uuid.UUID) -> domain.Workspace | None: + @staticmethod + async def get_workspace_for_user(workspace_id: uuid.UUID, user_id: uuid.UUID) -> domain.Workspace | None: membership = ( await domain.WorkspaceUser.filter(workspace_id=workspace_id, user_id=user_id) .prefetch_related('workspace') @@ -79,7 +86,8 @@ class Postgres(DatabaseBase, Database): ) return membership.workspace if membership else None - async def get_default_workspace_for_user(self, user_id: uuid.UUID) -> domain.Workspace | None: + @staticmethod + async def get_default_workspace_for_user(user_id: uuid.UUID) -> domain.Workspace | None: membership = ( await domain.WorkspaceUser.filter(user_id=user_id) .prefetch_related('workspace') @@ -88,16 +96,16 @@ class Postgres(DatabaseBase, Database): ) return membership.workspace if membership else None - async def get_workspace_membership( - self, workspace_id: uuid.UUID, user_id: uuid.UUID - ) -> domain.WorkspaceUser | None: + @staticmethod + async def get_workspace_membership(workspace_id: uuid.UUID, user_id: uuid.UUID) -> domain.WorkspaceUser | None: return ( await domain.WorkspaceUser.filter(workspace_id=workspace_id, user_id=user_id) .prefetch_related('workspace', 'user', 'user__telegram_user', 'permissions', 'permission_scopes') .first() ) - async def get_workspace_members(self, workspace_id: uuid.UUID) -> list[domain.WorkspaceUser]: + @staticmethod + async def get_workspace_members(workspace_id: uuid.UUID) -> list[domain.WorkspaceUser]: return ( await domain.WorkspaceUser.filter(workspace_id=workspace_id) .prefetch_related('workspace', 'user__telegram_user', 'permissions', 'permission_scopes') @@ -105,32 +113,38 @@ class Postgres(DatabaseBase, Database): .all() ) - async def get_workspace_member(self, workspace_user_id: uuid.UUID) -> domain.WorkspaceUser | None: + @staticmethod + async def get_workspace_member(workspace_user_id: uuid.UUID) -> domain.WorkspaceUser | None: return ( await domain.WorkspaceUser.filter(id=workspace_user_id) .prefetch_related('workspace', 'user__telegram_user', 'permissions', 'permission_scopes') .first() ) - async def create_workspace_invite(self, invite: domain.WorkspaceInvite) -> None: + @staticmethod + async def create_workspace_invite(invite: domain.WorkspaceInvite) -> None: await invite.save() - async def update_workspace_invite(self, invite: domain.WorkspaceInvite) -> None: + @staticmethod + async def update_workspace_invite(invite: domain.WorkspaceInvite) -> None: await invite.save() - async def get_workspace_invite(self, invite_id: uuid.UUID) -> domain.WorkspaceInvite | None: + @staticmethod + async def get_workspace_invite(invite_id: uuid.UUID) -> domain.WorkspaceInvite | None: return ( await domain.WorkspaceInvite.filter(id=invite_id) .prefetch_related('workspace', 'user__telegram_user', 'invited_by__telegram_user') .first() ) + @staticmethod async def get_workspace_invite_by_user( - self, workspace_id: uuid.UUID, user_id: uuid.UUID + workspace_id: uuid.UUID, user_id: uuid.UUID ) -> domain.WorkspaceInvite | None: return await domain.WorkspaceInvite.get_or_none(workspace_id=workspace_id, user_id=user_id) - async def get_workspace_invites(self, workspace_id: uuid.UUID) -> list[domain.WorkspaceInvite]: + @staticmethod + async def get_workspace_invites(workspace_id: uuid.UUID) -> list[domain.WorkspaceInvite]: return ( await domain.WorkspaceInvite.filter(workspace_id=workspace_id) .prefetch_related('user__telegram_user', 'invited_by__telegram_user') @@ -138,8 +152,8 @@ class Postgres(DatabaseBase, Database): .all() ) + @staticmethod async def set_workspace_user_permissions( - self, workspace_user_id: uuid.UUID, global_permissions: set[domain.PermissionKey], scoped_permissions: list[tuple[domain.PermissionKey, domain.PermissionScopeType, uuid.UUID]], @@ -175,17 +189,18 @@ class Postgres(DatabaseBase, Database): await domain.WorkspaceUserPermissionScope.bulk_create(scope_objects) - async def create_login_token(self, login_token: domain.LoginToken) -> None: + @staticmethod + async def create_login_token(login_token: domain.LoginToken) -> None: await login_token.save() - async def create_creative(self, creative: domain.Creative) -> None: + @staticmethod + async def create_creative(creative: domain.Creative) -> None: await creative.save() - async def get_user(self, user_id: uuid.UUID | None = None, telegram_id: int | None = None) -> domain.User | None: + @staticmethod + async def get_user(user_id: uuid.UUID | None = None, telegram_id: int | None = None) -> domain.User | None: if user_id: - return ( - await domain.User.filter(id=user_id).prefetch_related('telegram_user').first() - ) + return await domain.User.filter(id=user_id).prefetch_related('telegram_user').first() elif telegram_id: return ( await domain.User.filter(telegram_user__telegram_id=telegram_id) @@ -195,27 +210,26 @@ class Postgres(DatabaseBase, Database): raise ValueError('Either user_id or telegram_id must be provided') - async def get_user_by_username(self, username: str) -> domain.User | None: + @staticmethod + async def get_user_by_username(username: str) -> domain.User | None: return ( - await domain.User.filter(telegram_user__username__iexact=username) - .prefetch_related('telegram_user') - .first() + await domain.User.filter(telegram_user__username__iexact=username).prefetch_related('telegram_user').first() ) - async def get_login_token(self, token: str) -> domain.LoginToken | None: + @staticmethod + async def get_login_token(token: str) -> domain.LoginToken | None: return await domain.LoginToken.get_or_none(token=token) - async def mark_token_as_used(self, token: str) -> None: + @staticmethod + async def mark_token_as_used(token: str) -> None: updated = await domain.LoginToken.filter(token=token, used_at__isnull=True).update(used_at=timezone.now()) if updated == 0: raise domain.LoginTokenAlreadyUsed() # либо нет токена, либо он уже использован + @staticmethod async def get_channel( - self, - channel_id: uuid.UUID | None = None, - telegram_id: int | None = None, - username: str | None = None, + channel_id: uuid.UUID | None = None, telegram_id: int | None = None, username: str | None = None ) -> domain.Channel | None: if channel_id: return await domain.Channel.get_or_none(id=channel_id) @@ -225,16 +239,16 @@ class Postgres(DatabaseBase, Database): return await domain.Channel.filter(username__iexact=username).first() return None - async def create_channel(self, channel: domain.Channel) -> None: + @staticmethod + async def create_channel(channel: domain.Channel) -> None: await channel.save() - async def update_channel(self, channel: domain.Channel) -> None: + @staticmethod + async def update_channel(channel: domain.Channel) -> None: await channel.save() - async def delete_channel(self, channel_id: uuid.UUID) -> None: - await domain.Channel.filter(id=channel_id).delete() - - async def search_channels(self, username_query: str | None = None) -> list[domain.Channel]: + @staticmethod + async def search_channels(username_query: str | None = None) -> list[domain.Channel]: query = domain.Channel.all() if username_query: @@ -243,8 +257,9 @@ class Postgres(DatabaseBase, Database): return await query.all() + @staticmethod async def get_project( - self, workspace_id: uuid.UUID, project_id: uuid.UUID | None = None, channel_id: uuid.UUID | None = None + workspace_id: uuid.UUID, project_id: uuid.UUID | None = None, channel_id: uuid.UUID | None = None ) -> domain.Project | None: query = domain.Project.filter(workspace_id=workspace_id, deleted_at__isnull=True).prefetch_related('channel') if project_id: @@ -253,9 +268,8 @@ class Postgres(DatabaseBase, Database): query = query.filter(channel_id=channel_id) return await query.first() - async def get_project_for_user_by_telegram( - self, user_id: uuid.UUID, channel_telegram_id: int - ) -> domain.Project | None: + @staticmethod + async def get_project_for_user_by_telegram(user_id: uuid.UUID, channel_telegram_id: int) -> domain.Project | None: return ( await domain.Project.filter( channel__telegram_id=channel_telegram_id, workspace__workspace_users__user_id=user_id @@ -264,17 +278,20 @@ class Postgres(DatabaseBase, Database): .first() ) - async def get_project_by_channel_telegram(self, channel_telegram_id: int) -> domain.Project | None: + @staticmethod + async def get_project_by_channel_telegram(channel_telegram_id: int) -> domain.Project | None: return await domain.Project.filter(channel__telegram_id=channel_telegram_id).prefetch_related('channel').first() - async def create_project(self, project: domain.Project) -> None: + @staticmethod + async def create_project(project: domain.Project) -> None: await project.save() - async def update_project(self, project: domain.Project) -> None: + @staticmethod + async def update_project(project: domain.Project) -> None: await project.save() + @staticmethod async def get_workspace_projects( - self, workspace_id: uuid.UUID, allowed_project_ids: set[uuid.UUID] | None = None, include_archived: bool = False, @@ -291,14 +308,16 @@ class Postgres(DatabaseBase, Database): return await query.order_by('created_at').all() - async def archive_project(self, workspace_id: uuid.UUID, project_id: uuid.UUID) -> None: + @staticmethod + async def archive_project(workspace_id: uuid.UUID, project_id: uuid.UUID) -> None: project = await domain.Project.get_or_none(id=project_id, workspace_id=workspace_id) if not project: raise domain.ProjectNotFound() project.status = domain.ProjectStatus.ARCHIVED await project.save() - async def delete_project(self, workspace_id: uuid.UUID, project_id: uuid.UUID) -> None: + @staticmethod + async def delete_project(workspace_id: uuid.UUID, project_id: uuid.UUID) -> None: project = await domain.Project.get_or_none(id=project_id, workspace_id=workspace_id) if not project: raise domain.ProjectNotFound() @@ -307,20 +326,23 @@ class Postgres(DatabaseBase, Database): project.deleted_at = datetime.datetime.now() await project.save() - async def check_channel_exists_in_workspace(self, channel_id: uuid.UUID, workspace_id: uuid.UUID) -> bool: + @staticmethod + async def check_channel_exists_in_workspace(channel_id: uuid.UUID, workspace_id: uuid.UUID) -> bool: project = await domain.Project.get_or_none(channel_id=channel_id, workspace_id=workspace_id) return project is not None - async def get_creative(self, workspace_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Creative | None: - return await domain.Creative.get_or_none( - id=creative_id, project__workspace_id=workspace_id - ).prefetch_related('project', 'project__channel') + @staticmethod + async def get_creative(workspace_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Creative | None: + return await domain.Creative.get_or_none(id=creative_id, project__workspace_id=workspace_id).prefetch_related( + 'project', 'project__channel' + ) - async def update_creative(self, creative: domain.Creative) -> None: + @staticmethod + async def update_creative(creative: domain.Creative) -> None: await creative.save() + @staticmethod async def get_workspace_creatives( - self, workspace_id: uuid.UUID, project_id: uuid.UUID | None = None, include_archived: bool = False, @@ -340,24 +362,23 @@ class Postgres(DatabaseBase, Database): return await query.prefetch_related('project', 'project__channel').order_by('-created_at').all() - async def delete_creative(self, creative_id: uuid.UUID) -> None: + @staticmethod + async def delete_creative(creative_id: uuid.UUID) -> None: await domain.Creative.filter(id=creative_id).delete() - # Placement methods (user-managed, запланированные размещения) - async def create_placement(self, placement: domain.Placement) -> None: + @staticmethod + async def create_placement(placement: domain.Placement) -> None: await placement.save() - async def create_placements(self, placements: list[domain.Placement]) -> None: - if placements: - await domain.Placement.bulk_create(placements) - - async def get_placement(self, workspace_id: uuid.UUID, placement_id: uuid.UUID) -> domain.Placement | None: - return await domain.Placement.get_or_none( - id=placement_id, project__workspace_id=workspace_id - ).prefetch_related('project', 'project__channel', 'channel', 'creative') + @staticmethod + async def get_placement(workspace_id: uuid.UUID, placement_id: uuid.UUID) -> domain.Placement | None: + return await domain.Placement.get_or_none(id=placement_id, project__workspace_id=workspace_id).prefetch_related( + 'project', 'project__channel', 'channel', 'creative' + ) + @staticmethod async def get_project_placements( - self, workspace_id: uuid.UUID, project_id: uuid.UUID, include_archived: bool = False + workspace_id: uuid.UUID, project_id: uuid.UUID, include_archived: bool = False ) -> list[domain.Placement]: query = domain.Placement.filter(project__workspace_id=workspace_id, project_id=project_id) @@ -370,11 +391,8 @@ class Postgres(DatabaseBase, Database): .all() ) - async def update_placement_user(self, placement: domain.Placement) -> None: - await placement.save() - - # Publication methods (system-managed, фактически размещенные посты) - async def get_publication(self, workspace_id: uuid.UUID, publication_id: uuid.UUID) -> domain.Publication | None: + @staticmethod + async def get_publication(workspace_id: uuid.UUID, publication_id: uuid.UUID) -> domain.Publication | None: return await domain.Publication.get_or_none( id=publication_id, placement__project__workspace_id=workspace_id ).prefetch_related( @@ -387,8 +405,8 @@ class Postgres(DatabaseBase, Database): 'post__channel', ) + @staticmethod async def get_workspace_publications( - self, workspace_id: uuid.UUID, project_id: uuid.UUID | None = None, placement_channel_id: uuid.UUID | None = None, @@ -433,16 +451,12 @@ class Postgres(DatabaseBase, Database): .all() ) - async def create_publication(self, publication: domain.Publication) -> None: + @staticmethod + async def create_publication(publication: domain.Publication) -> None: await publication.save() - async def update_publication(self, publication: domain.Publication) -> None: - await publication.save() - - async def delete_publication(self, publication_id: uuid.UUID) -> None: - await domain.Publication.filter(id=publication_id).delete() - - async def get_publication_by_invite_link(self, invite_link: str) -> domain.Publication | None: + @staticmethod + async def get_publication_by_invite_link(invite_link: str) -> domain.Publication | None: return await domain.Publication.get_or_none(placement__invite_link=invite_link).prefetch_related( 'placement', 'placement__project', @@ -452,21 +466,22 @@ class Postgres(DatabaseBase, Database): ) # Subscription methods - async def create_subscription(self, subscription: domain.Subscription) -> None: + @staticmethod + async def create_subscription(subscription: domain.Subscription) -> None: await subscription.save() + @staticmethod async def get_subscription_by_subscriber_and_publication( - self, telegram_user_id: uuid.UUID, publication_id: uuid.UUID + telegram_user_id: uuid.UUID, publication_id: uuid.UUID ) -> domain.Subscription | None: - return await domain.Subscription.get_or_none( - telegram_user_id=telegram_user_id, publication_id=publication_id - ) + return await domain.Subscription.get_or_none(telegram_user_id=telegram_user_id, publication_id=publication_id) - async def update_subscription(self, subscription: domain.Subscription) -> None: + @staticmethod + async def update_subscription(subscription: domain.Subscription) -> None: await subscription.save() + @staticmethod async def get_subscriptions_for_publications( - self, publication_ids: list[uuid.UUID], *, date_from: datetime.datetime | None = None, @@ -484,8 +499,9 @@ class Postgres(DatabaseBase, Database): return await query.all() + @staticmethod async def get_active_subscriptions_by_subscriber_and_project( - self, telegram_user_id: uuid.UUID, project_id: uuid.UUID + telegram_user_id: uuid.UUID, project_id: uuid.UUID ) -> list[domain.Subscription]: return ( await domain.Subscription.filter( @@ -497,8 +513,9 @@ class Postgres(DatabaseBase, Database): .all() ) + @staticmethod async def get_active_subscription_by_subscriber_and_project( - self, telegram_user_id: uuid.UUID, project_id: uuid.UUID + telegram_user_id: uuid.UUID, project_id: uuid.UUID ) -> domain.Subscription | None: return ( await domain.Subscription.filter( @@ -510,26 +527,9 @@ class Postgres(DatabaseBase, Database): .first() ) - # Post methods - async def create_post(self, post: domain.Post) -> None: - await post.save() - - async def get_post(self, post_id: uuid.UUID) -> domain.Post | None: - return await domain.Post.get_or_none(id=post_id).prefetch_related('channel') - - async def get_post_by_channel_and_message(self, channel_id: uuid.UUID, message_id: int) -> domain.Post | None: - return await domain.Post.get_or_none(channel_id=channel_id, message_id=message_id).prefetch_related('channel') - - # Post views history - async def create_post_views_history(self, history: domain.PostViewsHistory) -> None: - await history.save() - + @staticmethod async def get_views_history( - self, - post_id: uuid.UUID, - *, - from_date: datetime.datetime | None = None, - to_date: datetime.datetime | None = None, + post_id: uuid.UUID, *, from_date: datetime.datetime | None = None, to_date: datetime.datetime | None = None ) -> list[domain.PostViewsHistory]: query = domain.PostViewsHistory.filter(post_id=post_id) @@ -540,15 +540,8 @@ class Postgres(DatabaseBase, Database): return await query.order_by('fetched_at').all() - async def get_latest_views_data(self, post_id: uuid.UUID) -> tuple[int, datetime.datetime] | None: - latest = await domain.PostViewsHistory.filter(post_id=post_id).order_by('-fetched_at').first() - if latest: - return (latest.views_count, latest.fetched_at) - return None - - async def get_latest_views_data_batch( - self, post_ids: list[uuid.UUID] - ) -> dict[uuid.UUID, tuple[int, datetime.datetime]]: + @staticmethod + async def get_latest_views_data_batch(post_ids: list[uuid.UUID]) -> dict[uuid.UUID, tuple[int, datetime.datetime]]: if not post_ids: return {} @@ -561,13 +554,16 @@ class Postgres(DatabaseBase, Database): return results # Count methods - async def count_publications_by_creative(self, creative_id: uuid.UUID) -> int: + @staticmethod + async def count_publications_by_creative(creative_id: uuid.UUID) -> int: return await domain.Publication.filter(placement__creative_id=creative_id).count() - async def count_subscriptions_by_publication(self, publication_id: uuid.UUID) -> int: + @staticmethod + async def count_subscriptions_by_publication(publication_id: uuid.UUID) -> int: return await domain.Subscription.filter(publication_id=publication_id).count() - async def count_publications_by_creative_batch(self, creative_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]: + @staticmethod + async def count_publications_by_creative_batch(creative_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]: if not creative_ids: return {} @@ -583,7 +579,8 @@ class Postgres(DatabaseBase, Database): counts = {row['placement__creative_id']: row['count'] for row in results} return {cid: counts.get(cid, 0) for cid in creative_ids} - async def count_subscriptions_by_publication_batch(self, publication_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]: + @staticmethod + async def count_subscriptions_by_publication_batch(publication_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]: if not publication_ids: return {} @@ -599,5 +596,6 @@ class Postgres(DatabaseBase, Database): counts = {row['publication_id']: row['count'] for row in results} return {pid: counts.get(pid, 0) for pid in publication_ids} - async def has_publications_for_creative(self, creative_id: uuid.UUID) -> bool: + @staticmethod + async def has_publications_for_creative(creative_id: uuid.UUID) -> bool: return await domain.Publication.filter(placement__creative_id=creative_id).exists() diff --git a/src/adapter/telegram_bot.py b/src/adapter/telegram_bot.py index c61b5e9..e64a298 100644 --- a/src/adapter/telegram_bot.py +++ b/src/adapter/telegram_bot.py @@ -12,10 +12,6 @@ class TelegramBot(TelegramBase, TelegramBotWriter): async def send_message(self, text: str, chat_id: int) -> None: await self.bot.send_message(chat_id=chat_id, text=text) - async def send_message_with_button(self, text: str, chat_id: int, button_text: str, button_url: str) -> None: - keyboard = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text=button_text, url=button_url)]]) - await self.bot.send_message(chat_id=chat_id, text=text, reply_markup=keyboard) - async def create_chat_invite_link(self, chat_id: int, requires_approval: bool = False) -> str: invite_link = await self.bot.create_chat_invite_link(chat_id=chat_id, creates_join_request=requires_approval) return invite_link.invite_link @@ -28,5 +24,3 @@ class TelegramBot(TelegramBase, TelegramBotWriter): async def edit_message_reply_markup(self, chat_id: int, message_id: int) -> None: await self.bot.edit_message_reply_markup(chat_id=chat_id, message_id=message_id, reply_markup=None) - - diff --git a/src/controller/http_v1/internal.py b/src/controller/http_v1/internal.py index df1d2b2..31dd855 100644 --- a/src/controller/http_v1/internal.py +++ b/src/controller/http_v1/internal.py @@ -150,7 +150,6 @@ async def _handle_chat_member_updated(event: dict[str, Any]) -> None: if chat.get('type') not in ['channel', 'supergroup']: return - from_user = event.get('from') member_user = event.get('new_chat_member', {}).get('user') if not member_user: log.error('No user in chat_member_updated event') diff --git a/src/dto/internal.py b/src/dto/internal.py deleted file mode 100644 index eb3dd0e..0000000 --- a/src/dto/internal.py +++ /dev/null @@ -1,6 +0,0 @@ -from pydantic import BaseModel - - -class CreateLoginTokenRequest(BaseModel): - telegram_id: int - username: str | None = None diff --git a/src/dto/placement.py b/src/dto/placement.py index a6567b9..ca51ff4 100644 --- a/src/dto/placement.py +++ b/src/dto/placement.py @@ -7,8 +7,6 @@ from src import domain class PublicationOutput(pydantic.BaseModel): - """Публикация - фактический пост, мониторимый системой""" - id: uuid.UUID project_id: uuid.UUID project_channel_title: str | None @@ -45,23 +43,3 @@ class GetPublicationInput(pydantic.BaseModel): publication_id: uuid.UUID user_id: uuid.UUID workspace_id: uuid.UUID - - -class CreatePublicationInput(pydantic.BaseModel): - placement_id: uuid.UUID - placement_date: datetime.datetime - cost: float | None = None - comment: str | None = None - - -class UpdatePublicationInput(pydantic.BaseModel): - placement_date: datetime.datetime | None = None - cost: float | None = None - comment: str | None = None - status: domain.PublicationStatus | None = None - - -class DeletePublicationInput(pydantic.BaseModel): - publication_id: uuid.UUID - user_id: uuid.UUID - workspace_id: uuid.UUID diff --git a/src/usecase/__init__.py b/src/usecase/__init__.py index 3934a98..fa9dc5d 100644 --- a/src/usecase/__init__.py +++ b/src/usecase/__init__.py @@ -1,4 +1,3 @@ -import datetime import typing from dataclasses import dataclass from uuid import UUID @@ -6,6 +5,8 @@ from uuid import UUID if typing.TYPE_CHECKING: from aiogram.types import InlineKeyboardButton + from src.adapter.postgres import Postgres + from src import domain from .analytics.get_channel_analytics import get_channel_analytics @@ -56,237 +57,9 @@ from .workspace.update_workspace import update_workspace from .workspace.update_workspace_member_permissions import update_workspace_member_permissions -class Database(typing.Protocol): - def transaction(self) -> typing.AsyncContextManager[None]: ... - - async def get_user( - self, - user_id: UUID | None = None, - telegram_id: int | None = None, - ) -> domain.User | None: ... - - async def get_user_by_username(self, username: str) -> domain.User | None: ... - - async def create_user(self, user: domain.User) -> None: ... - - async def get_telegram_user( - self, - telegram_user_id: UUID | None = None, - telegram_id: int | None = None, - ) -> domain.TelegramUser | None: ... - - async def create_telegram_user(self, telegram_user: domain.TelegramUser) -> None: ... - - async def update_telegram_user(self, telegram_user: domain.TelegramUser) -> None: ... - - async def create_workspace(self, workspace: domain.Workspace) -> None: ... - - async def update_workspace(self, workspace: domain.Workspace) -> None: ... - - async def delete_workspace(self, workspace_id: UUID) -> None: ... - - async def add_user_to_workspace(self, workspace_id: UUID, user_id: UUID) -> domain.WorkspaceUser: ... - - async def set_workspace_user_permissions( - self, - workspace_user_id: UUID, - global_permissions: set[domain.PermissionKey], - scoped_permissions: list[tuple[domain.PermissionKey, domain.PermissionScopeType, UUID]], - ) -> None: ... - - async def update_workspace_user(self, workspace_user: domain.WorkspaceUser) -> None: ... - - async def get_user_workspaces(self, user_id: UUID) -> list[domain.WorkspaceUser]: ... - - async def get_workspace(self, workspace_id: UUID) -> domain.Workspace | None: ... - - async def get_workspace_for_user(self, workspace_id: UUID, user_id: UUID) -> domain.Workspace | None: ... - - async def get_default_workspace_for_user(self, user_id: UUID) -> domain.Workspace | None: ... - - async def get_workspace_membership(self, workspace_id: UUID, user_id: UUID) -> domain.WorkspaceUser | None: ... - - async def get_workspace_members(self, workspace_id: UUID) -> list[domain.WorkspaceUser]: ... - - async def get_workspace_member(self, workspace_user_id: UUID) -> domain.WorkspaceUser | None: ... - - async def create_workspace_invite(self, invite: domain.WorkspaceInvite) -> None: ... - - async def update_workspace_invite(self, invite: domain.WorkspaceInvite) -> None: ... - - async def get_workspace_invite(self, invite_id: UUID) -> domain.WorkspaceInvite | None: ... - - async def get_workspace_invite_by_user( - self, workspace_id: UUID, user_id: UUID - ) -> domain.WorkspaceInvite | None: ... - - async def get_workspace_invites(self, workspace_id: UUID) -> list[domain.WorkspaceInvite]: ... - - async def create_login_token(self, login_token: domain.LoginToken) -> None: ... - - async def create_creative(self, creative: domain.Creative) -> None: ... - - async def get_login_token(self, token: str) -> domain.LoginToken | None: ... - - async def mark_token_as_used(self, token: str) -> None: ... - - async def update_user(self, user: domain.User) -> None: ... - - async def get_channel( - self, - channel_id: UUID | None = None, - telegram_id: int | None = None, - username: str | None = None, - ) -> domain.Channel | None: ... - - async def create_channel(self, channel: domain.Channel) -> None: ... - - async def update_channel(self, channel: domain.Channel) -> None: ... - - async def delete_channel(self, channel_id: UUID) -> None: ... - - async def search_channels(self, username_query: str | None = None) -> list[domain.Channel]: ... - - async def get_project( - self, workspace_id: UUID, project_id: UUID | None = None, channel_id: UUID | None = None - ) -> domain.Project | None: ... - - async def get_project_for_user_by_telegram( - self, user_id: UUID, channel_telegram_id: int - ) -> domain.Project | None: ... - - async def get_project_by_channel_telegram(self, channel_telegram_id: int) -> domain.Project | None: ... - - async def create_project(self, project: domain.Project) -> None: ... - - async def update_project(self, project: domain.Project) -> None: ... - - async def get_workspace_projects( - self, workspace_id: UUID, allowed_project_ids: set[UUID] | None = None, include_archived: bool = False - ) -> list[domain.Project]: ... - - async def archive_project(self, workspace_id: UUID, project_id: UUID) -> None: ... - - async def delete_project(self, workspace_id: UUID, project_id: UUID) -> None: ... - - async def check_channel_exists_in_workspace(self, channel_id: UUID, workspace_id: UUID) -> bool: ... - - # Placement methods (user-managed, бывший PurchaseChannel) - async def create_placement(self, placement: domain.Placement) -> None: ... - - async def create_placements(self, placements: list[domain.Placement]) -> None: ... - - async def get_placement(self, workspace_id: UUID, placement_id: UUID) -> domain.Placement | None: ... - - async def get_project_placements( - self, workspace_id: UUID, project_id: UUID, include_archived: bool = False - ) -> list[domain.Placement]: ... - - async def update_placement_user(self, placement: domain.Placement) -> None: ... - - async def get_creative(self, workspace_id: UUID, creative_id: UUID) -> domain.Creative | None: ... - - async def update_creative(self, creative: domain.Creative) -> None: ... - - async def get_workspace_creatives( - self, - workspace_id: UUID, - project_id: UUID | None = None, - include_archived: bool = False, - allowed_project_ids: set[UUID] | None = None, - ) -> list[domain.Creative]: ... - - async def delete_creative(self, creative_id: UUID) -> None: ... - - # Publication methods (system-managed, бывший Placement) - async def get_publication(self, workspace_id: UUID, publication_id: UUID) -> domain.Publication | None: ... - - async def get_workspace_publications( - self, - workspace_id: UUID, - project_id: UUID | None = None, - placement_channel_id: UUID | None = None, - creative_id: UUID | None = None, - include_archived: bool = False, - allowed_project_ids: set[UUID] | None = None, - date_from: datetime.datetime | None = None, - date_to: datetime.datetime | None = None, - ) -> list[domain.Publication]: ... - - async def create_publication(self, publication: domain.Publication) -> None: ... - - async def update_publication(self, publication: domain.Publication) -> None: ... - - async def delete_publication(self, publication_id: UUID) -> None: ... - - async def get_publication_by_invite_link(self, invite_link: str) -> domain.Publication | None: ... - - # Subscription methods - async def create_subscription(self, subscription: domain.Subscription) -> None: ... - - async def get_subscription_by_subscriber_and_publication( - self, telegram_user_id: UUID, publication_id: UUID - ) -> domain.Subscription | None: ... - - async def update_subscription(self, subscription: domain.Subscription) -> None: ... - - async def get_subscriptions_for_publications( - self, - publication_ids: list[UUID], - *, - date_from: datetime.datetime | None = None, - date_to: datetime.datetime | None = None, - ) -> list[domain.Subscription]: ... - - async def get_active_subscriptions_by_subscriber_and_project( - self, telegram_user_id: UUID, project_id: UUID - ) -> list[domain.Subscription]: ... - - async def get_active_subscription_by_subscriber_and_project( - self, telegram_user_id: UUID, project_id: UUID - ) -> domain.Subscription | None: ... - - # Count methods - async def count_publications_by_creative(self, creative_id: UUID) -> int: ... - - async def count_subscriptions_by_publication(self, publication_id: UUID) -> int: ... - - async def count_publications_by_creative_batch(self, creative_ids: list[UUID]) -> dict[UUID, int]: ... - - async def count_subscriptions_by_publication_batch( - self, publication_ids: list[UUID] - ) -> dict[UUID, int]: ... - - async def has_publications_for_creative(self, creative_id: UUID) -> bool: ... - - # Post methods - async def create_post(self, post: domain.Post) -> None: ... - - async def get_post(self, post_id: UUID) -> domain.Post | None: ... - - async def get_post_by_channel_and_message(self, channel_id: UUID, message_id: int) -> domain.Post | None: ... - - # Post views history - async def create_post_views_history(self, history: domain.PostViewsHistory) -> None: ... - - async def get_views_history( - self, - post_id: UUID, - *, - from_date: datetime.datetime | None = None, - to_date: datetime.datetime | None = None, - ) -> list[domain.PostViewsHistory]: ... - - async def get_latest_views_data(self, post_id: UUID) -> tuple[int, datetime.datetime] | None: ... - - async def get_latest_views_data_batch(self, post_ids: list[UUID]) -> dict[UUID, tuple[int, datetime.datetime]]: ... - - class TelegramBotWriter(typing.Protocol): async def send_message(self, text: str, chat_id: int) -> None: ... - async def send_message_with_button(self, text: str, chat_id: int, button_text: str, button_url: str) -> None: ... - async def send_message_with_inline_keyboard( self, text: str, chat_id: int, buttons: list[list['InlineKeyboardButton']] ) -> None: ... @@ -311,10 +84,6 @@ class Parser(typing.Protocol): class S3Storage(typing.Protocol): - async def connect(self) -> None: ... - - async def close(self) -> None: ... - async def upload(self, key: str, data: bytes, content_type: str) -> None: ... async def get(self, key: str) -> bytes: ... @@ -324,26 +93,14 @@ class S3Storage(typing.Protocol): @dataclass class Usecase: - database: Database + database: 'Postgres' telegram_bot: TelegramBotWriter jwt_encoder: JWTEncoder parser: Parser s3: S3Storage - async def ensure_workspace_access(self, workspace_id: UUID, user_id: UUID) -> domain.Workspace: - workspace = await self.database.get_workspace_for_user(workspace_id, user_id) - if not workspace: - raise domain.WorkspaceNotFound(workspace_id) - - return workspace - async def ensure_workspace_permission( - self, - workspace_id: UUID, - user_id: UUID, - permission: domain.PermissionKey, - *, - for_project_id: UUID | None = None, + self, workspace_id: UUID, user_id: UUID, permission: domain.PermissionKey, *, for_project_id: UUID | None = None ) -> domain.WorkspacePermissionContext: membership = await self.database.get_workspace_membership(workspace_id, user_id) if not membership or not membership.workspace: diff --git a/src/usecase/analytics/get_projects_analytics.py b/src/usecase/analytics/get_projects_analytics.py index f671107..ee246ae 100644 --- a/src/usecase/analytics/get_projects_analytics.py +++ b/src/usecase/analytics/get_projects_analytics.py @@ -233,7 +233,6 @@ async def get_projects_analytics( total_metrics.total_subscriptions += subs_count total_metrics.clicks_count += subs_count - views_count = 0 if publication.post and publication.post.id in views_map: views_count = views_map[publication.post.id][0] pd.total_views += views_count @@ -245,7 +244,9 @@ async def get_projects_analytics( placement = publication.placement if placement and placement.cost_before_bargain and placement.cost_before_bargain > cost: discount = placement.cost_before_bargain - cost - discount_percent = (discount / placement.cost_before_bargain) * 100 if placement.cost_before_bargain > 0 else 0.0 + discount_percent = ( + (discount / placement.cost_before_bargain) * 100 if placement.cost_before_bargain > 0 else 0.0 + ) pd.total_discounts += discount pd.discount_count += 1 @@ -296,4 +297,3 @@ async def get_projects_analytics( totals = _calculate_metrics(total_metrics, input.metrics) return dto.GetProjectsAnalyticsOutput(periods=periods, totals=totals) -