From 2fdd56c3e0c22348b36a81c8f51a3d4d5f0e0053 Mon Sep 17 00:00:00 2001 From: Artem Tsyrulnikov Date: Thu, 11 Dec 2025 22:10:46 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D1=84=D0=B8=D0=BA=D1=81=D1=8B=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=81=D0=BB=D0=B5=20=D1=80=D0=B5=D1=84=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=B8=D0=BD=D0=B3=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ange_external_channel_unique_constraint.py | 72 +++++++++++++++++++ shared/datebase_base.py | 5 ++ src/adapter/postgres.py | 21 ++---- src/domain/external_channel.py | 3 +- src/dto/__init__.py | 2 - src/usecase/__init__.py | 2 +- .../create_external_channel.py | 6 +- src/usecase/placement/create_placement.py | 5 -- 8 files changed, 92 insertions(+), 24 deletions(-) create mode 100644 migrations/models/1_20251211214049_change_external_channel_unique_constraint.py diff --git a/migrations/models/1_20251211214049_change_external_channel_unique_constraint.py b/migrations/models/1_20251211214049_change_external_channel_unique_constraint.py new file mode 100644 index 0000000..5c0f8ad --- /dev/null +++ b/migrations/models/1_20251211214049_change_external_channel_unique_constraint.py @@ -0,0 +1,72 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX IF EXISTS "uid_external_ch_telegra_13cca5"; + CREATE UNIQUE INDEX IF NOT EXISTS "uid_external_ch_user_id_289e6a" ON "external_channel" ("user_id", "telegram_id"); + CREATE INDEX IF NOT EXISTS "idx_external_ch_telegra_13cca5" ON "external_channel" ("telegram_id");""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX IF EXISTS "idx_external_ch_telegra_13cca5"; + DROP INDEX IF EXISTS "uid_external_ch_user_id_289e6a"; + CREATE UNIQUE INDEX IF NOT EXISTS "uid_external_ch_telegra_13cca5" ON "external_channel" ("telegram_id");""" + + +MODELS_STATE = ( + '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' + '8q//AeAg1wo=' +) diff --git a/shared/datebase_base.py b/shared/datebase_base.py index 3f7c998..b2093a4 100644 --- a/shared/datebase_base.py +++ b/shared/datebase_base.py @@ -1,3 +1,4 @@ +import logging import os import pydantic @@ -30,6 +31,10 @@ class DatabaseBase: self.config = config async def connect(self) -> None: + if self.config.ECHO: + logging.getLogger('tortoise.db_client').setLevel(logging.DEBUG) + logging.getLogger('tortoise').setLevel(logging.DEBUG) + await Tortoise.init( db_url=str(self.config.URL), modules={'models': ['src.domain', 'aerich.models']}, diff --git a/src/adapter/postgres.py b/src/adapter/postgres.py index 6dcbc6e..ade9059 100644 --- a/src/adapter/postgres.py +++ b/src/adapter/postgres.py @@ -1,4 +1,5 @@ import datetime +import logging import typing import uuid @@ -8,6 +9,8 @@ from tortoise.transactions import in_transaction from shared.datebase_base import DatabaseBase from src import domain +log = logging.getLogger(__name__) + class Postgres(DatabaseBase): def transaction(self) -> typing.AsyncContextManager[None]: @@ -89,26 +92,16 @@ class Postgres(DatabaseBase): return await query.first() async def get_external_channels_for_target(self, target_channel_id: uuid.UUID) -> list[domain.ExternalChannel]: - target = await domain.TargetChannel.filter(id=target_channel_id).prefetch_related('external_channels').first() - if not target: - return [] - return await target.external_channels.all() + return await domain.ExternalChannel.filter(target_channels__id=target_channel_id).all() async def get_user_external_channels(self, user_id: uuid.UUID) -> list[domain.ExternalChannel]: return await domain.ExternalChannel.filter(user_id=user_id).all() async def add_external_channel_to_targets( - self, external_channel_id: uuid.UUID, target_channel_ids: list[uuid.UUID] + self, external_channel: domain.ExternalChannel, target_channel_ids: list[uuid.UUID] ) -> None: - external_channel = await domain.ExternalChannel.get_or_none(id=external_channel_id) - - if not external_channel: - raise ValueError(f'ExternalChannel {external_channel_id} not found') - - for target_id in target_channel_ids: - target_channel = await domain.TargetChannel.get_or_none(id=target_id) - if target_channel: - await external_channel.target_channels.add(target_channel) + target_channels = await domain.TargetChannel.filter(id__in=target_channel_ids).all() + await external_channel.target_channels.add(*target_channels) async def remove_external_channel_from_target( self, external_channel_id: uuid.UUID, target_channel_id: uuid.UUID diff --git a/src/domain/external_channel.py b/src/domain/external_channel.py index 7e68b94..81a537e 100644 --- a/src/domain/external_channel.py +++ b/src/domain/external_channel.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: class ExternalChannel(TimestampedModel): id = fields.UUIDField(pk=True) - telegram_id = fields.BigIntField(unique=True, index=True) + telegram_id = fields.BigIntField(index=True) title = fields.CharField(max_length=255) username = fields.CharField(max_length=255, null=True, index=True) description = fields.TextField(null=True) @@ -28,3 +28,4 @@ class ExternalChannel(TimestampedModel): class Meta: table = 'external_channel' + unique_together = (('user_id', 'telegram_id'),) diff --git a/src/dto/__init__.py b/src/dto/__init__.py index 43f429f..f9b706d 100644 --- a/src/dto/__init__.py +++ b/src/dto/__init__.py @@ -2,7 +2,6 @@ __all__ = ( 'UpdateTargetChanPermissionsInput', 'GetUserTargetChansInput', 'GetUserTargetChansOutput', - 'DisconnectTargetChanInput', 'DisconnectTargetChanByTgIdInput', 'ConnectTargetChanInput', 'ConnectTargetChanOutput', @@ -110,7 +109,6 @@ from .target_channel import ( ConnectTargetChanInput, ConnectTargetChanOutput, DisconnectTargetChanByTgIdInput, - DisconnectTargetChanInput, GetUserTargetChansInput, GetUserTargetChansOutput, UpdateTargetChanPermissionsInput, diff --git a/src/usecase/__init__.py b/src/usecase/__init__.py index d56b69c..24416d1 100644 --- a/src/usecase/__init__.py +++ b/src/usecase/__init__.py @@ -80,7 +80,7 @@ class Database(typing.Protocol): async def get_user_external_channels(self, user_id: UUID) -> list[domain.ExternalChannel]: ... async def add_external_channel_to_targets( - self, external_channel_id: UUID, target_channel_ids: list[UUID] + self, external_channel: domain.ExternalChannel, target_channel_ids: list[UUID] ) -> None: ... async def remove_external_channel_from_target(self, external_channel_id: UUID, target_channel_id: UUID) -> None: ... diff --git a/src/usecase/external_channel/create_external_channel.py b/src/usecase/external_channel/create_external_channel.py index 848d194..688ba3b 100644 --- a/src/usecase/external_channel/create_external_channel.py +++ b/src/usecase/external_channel/create_external_channel.py @@ -13,6 +13,10 @@ log = logging.getLogger(__name__) async def create_external_channel( self: 'Usecase', input: dto.CreateExternalChannelInput, user_id: uuid.UUID ) -> dto.ExternalChannelOutput: + existing_channel = await self.database.get_external_channel(user_id, telegram_id=input.telegram_id) + if existing_channel: + raise domain.ExternalChannelAlreadyExists(input.telegram_id) + for target_id in input.target_channel_ids: target_channel = await self.database.get_target_channel(user_id, channel_id=target_id) @@ -32,7 +36,7 @@ async def create_external_channel( await self.database.create_external_channel(channel) if input.target_channel_ids: - await self.database.add_external_channel_to_targets(channel.id, input.target_channel_ids) + await self.database.add_external_channel_to_targets(channel, input.target_channel_ids) log.info( 'External channel %s created and linked to %s target channels', input.telegram_id, len(input.target_channel_ids) diff --git a/src/usecase/placement/create_placement.py b/src/usecase/placement/create_placement.py index d00a22c..70f9682 100644 --- a/src/usecase/placement/create_placement.py +++ b/src/usecase/placement/create_placement.py @@ -13,19 +13,14 @@ log = logging.getLogger(__name__) async def create_placement(self: 'Usecase', input: dto.CreatePlacementInput, user_id: uuid.UUID) -> dto.PlacementOutput: target_channel = await self.database.get_target_channel(user_id, channel_id=input.target_channel_id) if not target_channel: - log.warning('User %s attempted to create placement for unavailable target %s', user_id, input.target_channel_id) raise domain.TargetChannelNotFound(input.target_channel_id) external_channel = await self.database.get_external_channel(user_id, channel_id=input.external_channel_id) if not external_channel: - log.warning( - 'User %s attempted to create placement for unavailable external %s', user_id, input.external_channel_id - ) raise domain.ExternalChannelNotFound(input.external_channel_id) creative = await self.database.get_creative(user_id, input.creative_id) if not creative: - log.warning('User %s attempted to create placement for unavailable creative %s', user_id, input.creative_id) raise domain.CreativeNotFound(input.creative_id) requires_approval = input.invite_link_type == domain.InviteLinkType.APPROVAL