diff --git a/migrations/models/9_20260122191425_update.py b/migrations/models/9_20260122191425_update.py new file mode 100644 index 0000000..7799bef --- /dev/null +++ b/migrations/models/9_20260122191425_update.py @@ -0,0 +1,136 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + CREATE TABLE IF NOT EXISTS "creative_media" ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "media_type" VARCHAR(32) NOT NULL, + "media_file_id" VARCHAR(512) NOT NULL, + "media_s3_key" VARCHAR(512), + "position" INT NOT NULL, + "creative_id" UUID NOT NULL REFERENCES "creative" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_creative_me_creativ_dbd1c4" ON "creative_media" ("creative_id"); +CREATE UNIQUE INDEX IF NOT EXISTS "uid_creative_media_creative_pos" ON "creative_media" ("creative_id", "position"); + INSERT INTO "creative_media" ( + "id", "created_at", "updated_at", "deleted_at", + "media_type", "media_file_id", "media_s3_key", "position", "creative_id" +) +SELECT + gen_random_uuid(), + "created_at", + "updated_at", + "deleted_at", + "media_type", + "media_file_id", + "media_s3_key", + 0, + "id" +FROM "creative" +WHERE "media_file_id" IS NOT NULL AND "media_type" IS NOT NULL; + ALTER TABLE "creative" DROP COLUMN "media_type"; + ALTER TABLE "creative" DROP COLUMN "media_s3_key"; + ALTER TABLE "creative" DROP COLUMN "media_file_id";""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "creative" ADD "media_type" VARCHAR(32); + ALTER TABLE "creative" ADD "media_s3_key" VARCHAR(512); + ALTER TABLE "creative" ADD "media_file_id" VARCHAR(512); + UPDATE "creative" AS c + SET + "media_type" = cm."media_type", + "media_s3_key" = cm."media_s3_key", + "media_file_id" = cm."media_file_id" + FROM "creative_media" AS cm + WHERE cm."creative_id" = c."id" AND cm."position" = 0; + DROP TABLE IF EXISTS "creative_media";""" + + +MODELS_STATE = ( + "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" + "3zVIVkcJ3uUsL8p5f3fwEyCCxF" +) diff --git a/src/adapter/postgres.py b/src/adapter/postgres.py index a107c78..9b14dc7 100644 --- a/src/adapter/postgres.py +++ b/src/adapter/postgres.py @@ -348,7 +348,7 @@ class Postgres(DatabaseBase): @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' + 'project', 'project__channel', 'media_items' ) @staticmethod @@ -374,7 +374,7 @@ class Postgres(DatabaseBase): if not include_archived: query = query.filter(status=domain.CreativeStatus.ACTIVE) - return await query.prefetch_related('project', 'project__channel').order_by('-created_at').all() + return await query.prefetch_related('project', 'project__channel', 'media_items').order_by('-created_at').all() @staticmethod async def delete_creative(creative_id: uuid.UUID) -> None: diff --git a/src/adapter/telegram_bot.py b/src/adapter/telegram_bot.py index 30b0db3..9cc4a79 100644 --- a/src/adapter/telegram_bot.py +++ b/src/adapter/telegram_bot.py @@ -1,6 +1,7 @@ import logging +from typing import Any -from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, InputMediaPhoto, InputMediaVideo from shared.telegram_base import TelegramBase from src.usecase import TelegramBotWriter @@ -81,6 +82,32 @@ class TelegramBot(TelegramBase, TelegramBotWriter): reply_markup=keyboard, ) + async def send_media_group( + self, + chat_id: int, + media_items: list[Any], + caption: str | None = None, + parse_mode: str | None = None, + ) -> None: + if not media_items: + return + + group = [] + for index, item in enumerate(media_items): + item_caption = caption if index == 0 else None + if item.media_type == 'photo': + group.append( + InputMediaPhoto(media=item.media_file_id, caption=item_caption, parse_mode=parse_mode) + ) + elif item.media_type == 'video': + group.append( + InputMediaVideo(media=item.media_file_id, caption=item_caption, parse_mode=parse_mode) + ) + else: + raise ValueError(f'Unsupported media type for group: {item.media_type}') + + await self.bot.send_media_group(chat_id=chat_id, media=group) + async def edit_message_text(self, text: str, chat_id: int, message_id: int) -> None: await self.bot.edit_message_text(chat_id=chat_id, message_id=message_id, text=text, reply_markup=None) diff --git a/src/domain/__init__.py b/src/domain/__init__.py index 9bc3fc9..305bdc3 100644 --- a/src/domain/__init__.py +++ b/src/domain/__init__.py @@ -21,9 +21,12 @@ __all__ = ( 'PlacementType', 'PlacementPost', 'Creative', + 'CreativeMedia', 'replace_invite_link_with_tag', 'validate_media_size', + 'validate_media_items', 'MAX_CREATIVE_MEDIA_BYTES', + 'MAX_CREATIVE_MEDIA_ITEMS', 'validate_workspace_avatar_size', 'MAX_WORKSPACE_AVATAR_BYTES', 'Post', @@ -61,15 +64,20 @@ __all__ = ( 'CreativeInviteLinkNotFound', 'CreativeMultipleInviteLinks', 'CreativeMediaTooLarge', + 'CreativeMediaTooMany', + 'CreativeMediaGroupUnsupported', 'UserByUsernameNotFound', ) from .channel import Channel from .creative import ( MAX_CREATIVE_MEDIA_BYTES, + MAX_CREATIVE_MEDIA_ITEMS, Creative, + CreativeMedia, CreativeStatus, replace_invite_link_with_tag, + validate_media_items, validate_media_size, ) from .error import ( @@ -78,7 +86,9 @@ from .error import ( ChannelNotFound, CreativeInUse, CreativeInviteLinkNotFound, + CreativeMediaGroupUnsupported, CreativeMediaTooLarge, + CreativeMediaTooMany, CreativeMultipleInviteLinks, CreativeNotFound, LoginTokenAlreadyUsed, diff --git a/src/domain/creative.py b/src/domain/creative.py index 061b760..88d736f 100644 --- a/src/domain/creative.py +++ b/src/domain/creative.py @@ -6,7 +6,13 @@ from uuid import UUID from tortoise import fields from .base import TimestampedModel -from .error import CreativeInviteLinkNotFound, CreativeMediaTooLarge, CreativeMultipleInviteLinks +from .error import ( + CreativeInviteLinkNotFound, + CreativeMediaGroupUnsupported, + CreativeMediaTooLarge, + CreativeMediaTooMany, + CreativeMultipleInviteLinks, +) if TYPE_CHECKING: from .project import Project @@ -20,9 +26,6 @@ class CreativeStatus(str, enum.Enum): class Creative(TimestampedModel): name = fields.CharField(max_length=255) text = fields.TextField() - media_type = fields.CharField(max_length=32, null=True) - media_file_id = fields.CharField(max_length=512, null=True) - media_s3_key = fields.CharField(max_length=512, null=True) buttons = fields.JSONField(default=list) status = fields.CharEnumField(CreativeStatus, default=CreativeStatus.ACTIVE) @@ -37,7 +40,26 @@ class Creative(TimestampedModel): table = 'creative' +class CreativeMedia(TimestampedModel): + media_type = fields.CharField(max_length=32) + media_file_id = fields.CharField(max_length=512) + media_s3_key = fields.CharField(max_length=512, null=True) + position = fields.IntField() + + creative: fields.ForeignKeyRelation['Creative'] = fields.ForeignKeyField( + 'models.Creative', related_name='media_items', on_delete=fields.CASCADE, index=True + ) + + if TYPE_CHECKING: + creative_id: UUID + + class Meta: + table = 'creative_media' + unique_together = (('creative_id', 'position'),) + + MAX_CREATIVE_MEDIA_BYTES = 20 * 1024 * 1024 +MAX_CREATIVE_MEDIA_ITEMS = 10 _INVITE_LINK_HTML = re.compile( @@ -99,3 +121,10 @@ def validate_media_size(media_data: bytes | None) -> None: return if len(media_data) > MAX_CREATIVE_MEDIA_BYTES: raise CreativeMediaTooLarge(MAX_CREATIVE_MEDIA_BYTES) + + +def validate_media_items(media_types: list[str]) -> None: + if len(media_types) > MAX_CREATIVE_MEDIA_ITEMS: + raise CreativeMediaTooMany(MAX_CREATIVE_MEDIA_ITEMS) + if len(media_types) > 1 and "animation" in media_types: + raise CreativeMediaGroupUnsupported() diff --git a/src/domain/error.py b/src/domain/error.py index 320d040..f5f5f51 100644 --- a/src/domain/error.py +++ b/src/domain/error.py @@ -94,6 +94,17 @@ def CreativeMediaTooLarge(max_bytes: int) -> HTTPException: return HTTPException(status.HTTP_400_BAD_REQUEST, f'Creative media is too large (max {max_bytes} bytes)') +def CreativeMediaTooMany(max_items: int) -> HTTPException: + return HTTPException(status.HTTP_400_BAD_REQUEST, f'Creative media exceeds max items ({max_items})') + + +def CreativeMediaGroupUnsupported() -> HTTPException: + return HTTPException( + status.HTTP_400_BAD_REQUEST, + 'Creative media group supports only photo/video; animation allowed only as a single item', + ) + + def CreativeInUse(creative_id: uuid.UUID) -> HTTPException: return HTTPException( status.HTTP_400_BAD_REQUEST, diff --git a/src/dto/__init__.py b/src/dto/__init__.py index 6ef4eaa..278b462 100644 --- a/src/dto/__init__.py +++ b/src/dto/__init__.py @@ -35,6 +35,8 @@ __all__ = ( 'PostOutput', 'PlacementWithPostsOutput', 'CreativeButton', + 'CreativeMediaInput', + 'CreativeMediaItem', 'CreativeOutput', 'CreativePreviewOutput', 'GetCreativesInput', @@ -134,6 +136,8 @@ from .channel import ( from .creative import ( CreateCreativeInput, CreativeButton, + CreativeMediaInput, + CreativeMediaItem, CreativeOutput, CreativePreviewOutput, DeleteCreativeInput, diff --git a/src/dto/creative.py b/src/dto/creative.py index d9f3ee2..66d7bb3 100644 --- a/src/dto/creative.py +++ b/src/dto/creative.py @@ -11,12 +11,23 @@ class CreativeButton(pydantic.BaseModel): url: str +class CreativeMediaItem(pydantic.BaseModel): + media_type: str + media_file_id: str + position: int + + +class CreativeMediaInput(pydantic.BaseModel): + media_type: str + media_file_id: str + media_data: bytes | None = None + + class CreativeOutput(pydantic.BaseModel): id: uuid.UUID name: str text: str - media_type: str | None - media_file_id: str | None + media_items: list[CreativeMediaItem] buttons: list[CreativeButton] project_id: uuid.UUID project_channel_title: str @@ -29,8 +40,7 @@ class CreativePreviewOutput(pydantic.BaseModel): id: uuid.UUID name: str text: str - media_type: str | None - media_file_id: str | None + media_items: list[CreativeMediaItem] buttons: list[CreativeButton] @@ -54,18 +64,14 @@ class GetCreativeInput(pydantic.BaseModel): class CreateCreativeInput(pydantic.BaseModel): name: str text: str - media_type: str | None = None - media_file_id: str | None = None - media_data: bytes | None = None + media_items: list[CreativeMediaInput] = pydantic.Field(default_factory=list) buttons: list[CreativeButton] = pydantic.Field(default_factory=list) class UpdateCreativeInput(pydantic.BaseModel): name: str | None = None text: str | None = None - media_type: str | None = None - media_file_id: str | None = None - media_data: bytes | None = None + media_items: list[CreativeMediaInput] | None = None buttons: list[CreativeButton] | None = None status: domain.CreativeStatus | None = None diff --git a/src/usecase/__init__.py b/src/usecase/__init__.py index 845ca03..083ae55 100644 --- a/src/usecase/__init__.py +++ b/src/usecase/__init__.py @@ -62,6 +62,11 @@ from .workspace.update_workspace_avatar import update_workspace_avatar from .workspace.update_workspace_member_permissions import update_workspace_member_permissions +class MediaItem(typing.Protocol): + media_type: str + media_file_id: str + + class TelegramBotWriter(typing.Protocol): async def send_message( self, text: str, chat_id: int, parse_mode: str | None = None, disable_preview: bool = False @@ -86,6 +91,14 @@ class TelegramBotWriter(typing.Protocol): parse_mode: str | None = None, ) -> None: ... + async def send_media_group( + self, + chat_id: int, + media_items: list[MediaItem], + caption: str | None = None, + parse_mode: str | None = None, + ) -> None: ... + async def edit_message_text(self, text: str, chat_id: int, message_id: int) -> None: ... async def edit_message_reply_markup(self, chat_id: int, message_id: int) -> None: ... diff --git a/src/usecase/creative/create_creative.py b/src/usecase/creative/create_creative.py index 50ffd1f..5d02c44 100644 --- a/src/usecase/creative/create_creative.py +++ b/src/usecase/creative/create_creative.py @@ -22,36 +22,28 @@ async def create_creative( log.warning('User %s attempted to create creative for unavailable project %s', user_id, project_id) raise domain.ProjectNotFound(project_id) - domain.validate_media_size(input.media_data) - creative_text = domain.replace_invite_link_with_tag(input.text) - - media_s3_key: str | None = None - if input.media_data: - file_id = uuid.uuid4() - media_s3_key = f'creatives/{workspace_id}/{file_id}' - content_type = _get_content_type(input.media_type) - await self.s3.upload(media_s3_key, input.media_data, content_type) - log.info('Uploaded creative media to S3: %s', media_s3_key) + media_items = input.media_items or [] + domain.validate_media_items([item.media_type for item in media_items]) + for item in media_items: + domain.validate_media_size(item.media_data) creative = domain.Creative( name=input.name, text=creative_text, - media_type=input.media_type, - media_file_id=input.media_file_id, - media_s3_key=media_s3_key, buttons=[button.model_dump() for button in input.buttons], status=domain.CreativeStatus.ACTIVE, project_id=project.id, ) await self.database.create_creative(creative) + created_media = await _replace_media_items(self, creative.id, workspace_id, media_items) + return dto.CreativeOutput( id=creative.id, name=creative.name, text=creative.text, - media_type=creative.media_type, - media_file_id=creative.media_file_id, + media_items=created_media, buttons=creative.buttons, project_id=project.id, project_channel_title=project.channel.title, @@ -72,3 +64,35 @@ def _get_content_type(media_type: str | None) -> str: 'animation': 'image/gif', } return mapping.get(media_type, 'application/octet-stream') + + +async def _replace_media_items( + self: 'Usecase', + creative_id: uuid.UUID, + workspace_id: uuid.UUID, + media_items: list[dto.CreativeMediaInput], +) -> list[dto.CreativeMediaItem]: + created: list[dto.CreativeMediaItem] = [] + for position, item in enumerate(media_items): + media_s3_key: str | None = None + if item.media_data: + file_id = uuid.uuid4() + media_s3_key = f'creatives/{workspace_id}/{file_id}' + content_type = _get_content_type(item.media_type) + await self.s3.upload(media_s3_key, item.media_data, content_type) + log.info('Uploaded creative media to S3: %s', media_s3_key) + media = await domain.CreativeMedia.create( + creative_id=creative_id, + media_type=item.media_type, + media_file_id=item.media_file_id, + media_s3_key=media_s3_key, + position=position, + ) + created.append( + dto.CreativeMediaItem( + media_type=media.media_type, + media_file_id=media.media_file_id, + position=media.position, + ) + ) + return created diff --git a/src/usecase/creative/delete_creative.py b/src/usecase/creative/delete_creative.py index 7ade3be..10e78f9 100644 --- a/src/usecase/creative/delete_creative.py +++ b/src/usecase/creative/delete_creative.py @@ -27,15 +27,17 @@ async def delete_creative(self: 'Usecase', input: dto.DeleteCreativeInput) -> No log.warning('Creative %s is used in placement_posts and cannot be deleted', input.creative_id) raise domain.CreativeInUse(input.creative_id) - media_key = creative.media_s3_key - if media_key: + media_items = await creative.media_items.all() + media_keys = [item.media_s3_key for item in media_items if item.media_s3_key] + if media_keys: async def delete_old_media() -> None: - try: - await self.s3.delete(media_key) - log.info('Deleted old creative media from S3: %s', media_key) - except Exception as e: - log.warning('Failed to delete old creative media from S3: %s', e) + for key in media_keys: + try: + await self.s3.delete(key) + log.info('Deleted old creative media from S3: %s', key) + except Exception as e: + log.warning('Failed to delete old creative media from S3: %s', e) asyncio.create_task(delete_old_media()) diff --git a/src/usecase/creative/get_creative.py b/src/usecase/creative/get_creative.py index 869de95..b4339c9 100644 --- a/src/usecase/creative/get_creative.py +++ b/src/usecase/creative/get_creative.py @@ -21,13 +21,24 @@ async def get_creative(self: 'Usecase', input: dto.GetCreativeInput) -> dto.Crea context.ensure_project_permission(domain.PermissionKey.PROJECTS_READ, creative.project_id) placements_count = await self.database.count_placement_posts_by_creative(creative.id) + media_rel = creative.media_items + if hasattr(media_rel, 'all'): + media_items = await media_rel.all().order_by('position') + else: + media_items = sorted(media_rel, key=lambda item: item.position) return dto.CreativeOutput( id=creative.id, name=creative.name, text=creative.text, - media_type=creative.media_type, - media_file_id=creative.media_file_id, + media_items=[ + dto.CreativeMediaItem( + media_type=item.media_type, + media_file_id=item.media_file_id, + position=item.position, + ) + for item in media_items + ], buttons=creative.buttons, project_id=creative.project_id, project_channel_title=creative.project.channel.title, diff --git a/src/usecase/creative/get_creatives.py b/src/usecase/creative/get_creatives.py index 1ad64a4..03f2894 100644 --- a/src/usecase/creative/get_creatives.py +++ b/src/usecase/creative/get_creatives.py @@ -27,19 +27,32 @@ async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> list[d creative_ids = [c.id for c in creatives] placements_counts = await self.database.count_placement_posts_by_creative_batch(creative_ids) - return [ - dto.CreativeOutput( - id=creative.id, - name=creative.name, - text=creative.text, - media_type=creative.media_type, - media_file_id=creative.media_file_id, - buttons=creative.buttons, - project_id=creative.project_id, - project_channel_title=creative.project.channel.title, - created_at=creative.created_at, - status=creative.status, - placements_count=placements_counts.get(creative.id, 0), + results: list[dto.CreativeOutput] = [] + for creative in creatives: + media_rel = creative.media_items + if hasattr(media_rel, 'all'): + media_items = await media_rel.all().order_by('position') + else: + media_items = sorted(media_rel, key=lambda item: item.position) + results.append( + dto.CreativeOutput( + id=creative.id, + name=creative.name, + text=creative.text, + media_items=[ + dto.CreativeMediaItem( + media_type=item.media_type, + media_file_id=item.media_file_id, + position=item.position, + ) + for item in media_items + ], + buttons=creative.buttons, + project_id=creative.project_id, + project_channel_title=creative.project.channel.title, + created_at=creative.created_at, + status=creative.status, + placements_count=placements_counts.get(creative.id, 0), + ) ) - for creative in creatives - ] + return results diff --git a/src/usecase/creative/update_creative.py b/src/usecase/creative/update_creative.py index 6385ef4..2906dae 100644 --- a/src/usecase/creative/update_creative.py +++ b/src/usecase/creative/update_creative.py @@ -31,37 +31,12 @@ async def update_creative( creative.name = input.name if input.text: creative.text = domain.replace_invite_link_with_tag(input.text) - if input.media_data is not None: - domain.validate_media_size(input.media_data) - - # Удаляем старое медиа из S3 в фоне - old_media_key = creative.media_s3_key - if old_media_key: - - async def delete_old_media() -> None: - try: - await self.s3.delete(old_media_key) - log.info('Deleted old creative media from S3: %s', old_media_key) - except Exception as e: - log.warning('Failed to delete old creative media from S3: %s', e) - - asyncio.create_task(delete_old_media()) - - # Загружаем новое медиа в S3 - new_media_data = input.media_data - if new_media_data: - file_id = uuid.uuid4() - new_media_key = f'creatives/{workspace_id}/{file_id}' - content_type = _get_content_type(input.media_type) - await self.s3.upload(new_media_key, new_media_data, content_type) - log.info('Uploaded new creative media to S3: %s', new_media_key) - creative.media_s3_key = new_media_key - else: - creative.media_s3_key = None - if input.media_type is not None: - creative.media_type = input.media_type - if input.media_file_id is not None: - creative.media_file_id = input.media_file_id + media_items: list[dto.CreativeMediaInput] | None = None + if input.media_items is not None: + media_items = input.media_items + domain.validate_media_items([item.media_type for item in media_items]) + for item in media_items: + domain.validate_media_size(item.media_data) if input.buttons is not None: creative.buttons = [button.model_dump() for button in input.buttons] if input.status: @@ -69,14 +44,17 @@ async def update_creative( await self.database.update_creative(creative) + if media_items is not None: + await _replace_media_items(self, creative.id, workspace_id, media_items) + placements_count = await self.database.count_placement_posts_by_creative(creative.id) + creative_media = await _get_media_items(creative) return dto.CreativeOutput( id=creative.id, name=creative.name, text=creative.text, - media_type=creative.media_type, - media_file_id=creative.media_file_id, + media_items=creative_media, buttons=creative.buttons, project_id=creative.project_id, project_channel_title=creative.project.channel.title, @@ -96,3 +74,59 @@ def _get_content_type(media_type: str | None) -> str: 'animation': 'image/gif', } return mapping.get(media_type, 'application/octet-stream') + + +async def _replace_media_items( + self: 'Usecase', + creative_id: uuid.UUID, + workspace_id: uuid.UUID, + media_items: list[dto.CreativeMediaInput], +) -> None: + existing_items = await domain.CreativeMedia.filter(creative_id=creative_id).all() + if existing_items: + old_media_keys = [item.media_s3_key for item in existing_items if item.media_s3_key] + await domain.CreativeMedia.filter(creative_id=creative_id).delete() + + if old_media_keys: + + async def delete_old_media() -> None: + for key in old_media_keys: + try: + await self.s3.delete(key) + log.info('Deleted old creative media from S3: %s', key) + except Exception as e: + log.warning('Failed to delete old creative media from S3: %s', e) + + asyncio.create_task(delete_old_media()) + + for position, item in enumerate(media_items): + media_s3_key: str | None = None + if item.media_data: + file_id = uuid.uuid4() + media_s3_key = f'creatives/{workspace_id}/{file_id}' + content_type = _get_content_type(item.media_type) + await self.s3.upload(media_s3_key, item.media_data, content_type) + log.info('Uploaded new creative media to S3: %s', media_s3_key) + await domain.CreativeMedia.create( + creative_id=creative_id, + media_type=item.media_type, + media_file_id=item.media_file_id, + media_s3_key=media_s3_key, + position=position, + ) + + +async def _get_media_items(creative: domain.Creative) -> list[dto.CreativeMediaItem]: + media_rel = creative.media_items + if hasattr(media_rel, 'all'): + items = await media_rel.all().order_by('position') + else: + items = sorted(media_rel, key=lambda item: item.position) + return [ + dto.CreativeMediaItem( + media_type=item.media_type, + media_file_id=item.media_file_id, + position=item.position, + ) + for item in items + ] diff --git a/src/usecase/purchase/build_placement_creative.py b/src/usecase/purchase/build_placement_creative.py index 5aad845..08a0048 100644 --- a/src/usecase/purchase/build_placement_creative.py +++ b/src/usecase/purchase/build_placement_creative.py @@ -93,12 +93,23 @@ async def build_placement_creative( if not invite_link: raise domain.PlacementNotFound(placement.id) + media_rel = creative.media_items + if hasattr(media_rel, 'all'): + media_items = await media_rel.all().order_by('position') + else: + media_items = sorted(media_rel, key=lambda item: item.position) preview = dto.CreativePreviewOutput( id=creative.id, name=creative.name, text=_inject_invite_link(creative.text, invite_link), - media_type=creative.media_type, - media_file_id=creative.media_file_id, + media_items=[ + dto.CreativeMediaItem( + media_type=item.media_type, + media_file_id=item.media_file_id, + position=item.position, + ) + for item in media_items + ], buttons=_build_buttons(creative.buttons, invite_link), ) @@ -112,15 +123,24 @@ async def build_placement_creative( await self.telegram_bot.send_message(header, chat_id, parse_mode='HTML') keyboard_buttons = _build_keyboard_buttons(preview.buttons) - if preview.media_type and preview.media_file_id: - await self.telegram_bot.send_media_with_inline_keyboard( - text=preview.text, - chat_id=chat_id, - media_type=preview.media_type, - media_file_id=preview.media_file_id, - buttons=keyboard_buttons, - parse_mode='HTML', - ) + if preview.media_items: + if len(preview.media_items) == 1: + media_item = preview.media_items[0] + await self.telegram_bot.send_media_with_inline_keyboard( + text=preview.text, + chat_id=chat_id, + media_type=media_item.media_type, + media_file_id=media_item.media_file_id, + buttons=keyboard_buttons, + parse_mode='HTML', + ) + else: + await self.telegram_bot.send_media_group( + chat_id=chat_id, + media_items=preview.media_items, + caption=preview.text, + parse_mode='HTML', + ) elif keyboard_buttons: await self.telegram_bot.send_message_with_inline_keyboard( preview.text, diff --git a/tg_bot/backend/backend_client.go b/tg_bot/backend/backend_client.go index 62a3603..37dfaa3 100644 --- a/tg_bot/backend/backend_client.go +++ b/tg_bot/backend/backend_client.go @@ -28,7 +28,7 @@ type Client struct { func New(cfg Config) *Client { return &Client{ http: &http.Client{ - Timeout: 5 * time.Second, + Timeout: 60 * time.Second, }, baseURL: cfg.BaseURL, loginURL: cfg.LoginURL, @@ -369,17 +369,16 @@ func (c *Client) AcceptWorkspaceInvite( // ============================================================================ type Creative struct { - ID string `json:"id"` - Name string `json:"name"` - Text string `json:"text"` - MediaType string `json:"media_type"` - MediaFileID string `json:"media_file_id"` - Buttons []CreativeButton `json:"buttons"` - ProjectID string `json:"project_id"` - ProjectChannelTitle string `json:"project_channel_title"` - CreatedAt string `json:"created_at"` - Status string `json:"status"` - PlacementsCount int `json:"placements_count"` + ID string `json:"id"` + Name string `json:"name"` + Text string `json:"text"` + MediaItems []CreativeMediaItem `json:"media_items"` + Buttons []CreativeButton `json:"buttons"` + ProjectID string `json:"project_id"` + ProjectChannelTitle string `json:"project_channel_title"` + CreatedAt string `json:"created_at"` + Status string `json:"status"` + PlacementsCount int `json:"placements_count"` } type CreativeButton struct { @@ -387,6 +386,18 @@ type CreativeButton struct { URL string `json:"url"` } +type CreativeMediaItem struct { + MediaType string `json:"media_type"` + MediaFileID string `json:"media_file_id"` + Position int `json:"position"` +} + +type CreativeMediaInput struct { + MediaType string `json:"media_type"` + MediaFileID string `json:"media_file_id"` + MediaData []byte `json:"media_data,omitempty"` +} + type CreativesPage struct { Items []Creative `json:"items"` Total int `json:"total"` @@ -448,12 +459,10 @@ func (c *Client) GetCreative( } type CreateCreativeInput struct { - Name string `json:"name"` - Text string `json:"text"` - MediaType string `json:"media_type,omitempty"` - MediaFileID string `json:"media_file_id,omitempty"` - MediaData []byte `json:"media_data,omitempty"` - Buttons []CreativeButton `json:"buttons,omitempty"` + Name string `json:"name"` + Text string `json:"text"` + MediaItems []CreativeMediaInput `json:"media_items,omitempty"` + Buttons []CreativeButton `json:"buttons,omitempty"` } func (c *Client) CreateCreative( @@ -482,13 +491,11 @@ func (c *Client) CreateCreative( } type UpdateCreativeInput struct { - Name *string `json:"name,omitempty"` - Text *string `json:"text,omitempty"` - MediaType *string `json:"media_type,omitempty"` - MediaFileID *string `json:"media_file_id,omitempty"` - MediaData *[]byte `json:"media_data,omitempty"` - Buttons *[]CreativeButton `json:"buttons,omitempty"` - Status *string `json:"status,omitempty"` + Name *string `json:"name,omitempty"` + Text *string `json:"text,omitempty"` + MediaItems *[]CreativeMediaInput `json:"media_items,omitempty"` + Buttons *[]CreativeButton `json:"buttons,omitempty"` + Status *string `json:"status,omitempty"` } func (c *Client) UpdateCreative( @@ -625,12 +632,11 @@ type GetPlacementsOutput struct { } type CreativePreviewOutput struct { - ID string `json:"id"` - Name string `json:"name"` - Text string `json:"text"` - MediaType string `json:"media_type"` - MediaFileID string `json:"media_file_id"` - Buttons []CreativeButton `json:"buttons"` + ID string `json:"id"` + Name string `json:"name"` + Text string `json:"text"` + MediaItems []CreativeMediaItem `json:"media_items"` + Buttons []CreativeButton `json:"buttons"` } func (c *Client) CreatePlacements( diff --git a/tg_bot/bot/bot.go b/tg_bot/bot/bot.go index ff23bb3..fba8ab9 100644 --- a/tg_bot/bot/bot.go +++ b/tg_bot/bot/bot.go @@ -143,6 +143,12 @@ func (b *Bot) SetState(s State, mode RenderMode) { s.Enter(b, mode) } +func (b *Bot) MarkHandled() { + if b.exec != nil { + b.exec.handled = true + } +} + func isChannelChat(chatType string) bool { return chatType == "channel" || chatType == "supergroup" }