From 233afb5451a88d468576e94adcb50a4ff751e271 Mon Sep 17 00:00:00 2001 From: Artem Tsyrulnikov Date: Tue, 20 Jan 2026 22:33:31 +0300 Subject: [PATCH] workspace avatar and send builded creative --- migrations/models/4_20260120193804_update.py | 143 +++++++++--------- .../6_20260120222211_add_workspace_avatar.py | 92 +++++++++++ src/adapter/s3.py | 7 + src/adapter/telegram_bot.py | 67 +++++++- src/controller/http_v1/internal.py | 26 ++-- src/controller/http_v1/workspaces.py | 25 ++- src/domain/__init__.py | 8 + src/domain/creative.py | 8 +- src/domain/error.py | 4 + src/domain/placement.py | 2 +- src/domain/workspace.py | 12 ++ src/dto/__init__.py | 2 +- src/dto/purchase.py | 3 - src/dto/workspace.py | 1 + src/usecase/__init__.py | 27 +++- src/usecase/auth/validate_login_token.py | 2 +- .../purchase/build_placement_creative.py | 53 ++++++- src/usecase/purchase/create_placements.py | 6 +- src/usecase/purchase/get_placements.py | 4 +- src/usecase/purchase/update_placement.py | 4 +- src/usecase/workspace/create_workspace.py | 1 + .../workspace/delete_workspace_avatar.py | 42 +++++ src/usecase/workspace/get_workspaces.py | 7 + src/usecase/workspace/update_workspace.py | 1 + .../workspace/update_workspace_avatar.py | 53 +++++++ tg_bot/screens/purchase_optional_details.go | 41 +---- 26 files changed, 490 insertions(+), 151 deletions(-) create mode 100644 migrations/models/6_20260120222211_add_workspace_avatar.py create mode 100644 src/usecase/workspace/delete_workspace_avatar.py create mode 100644 src/usecase/workspace/update_workspace_avatar.py diff --git a/migrations/models/4_20260120193804_update.py b/migrations/models/4_20260120193804_update.py index f9b4f8a..e142aa5 100644 --- a/migrations/models/4_20260120193804_update.py +++ b/migrations/models/4_20260120193804_update.py @@ -1,3 +1,4 @@ +# ruff: noqa from tortoise import BaseDBAsyncClient RUN_IN_TRANSACTION = True @@ -32,75 +33,75 @@ async def downgrade(db: BaseDBAsyncClient) -> str: MODELS_STATE = ( - "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" - "f/C4NtvdBk9VSAWNZJcSZj+9vP8f6Gvh+w==" + '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' + 'f/C4NtvdBk9VSAWNZJcSZj+9vP8f6Gvh+w==' ) diff --git a/migrations/models/6_20260120222211_add_workspace_avatar.py b/migrations/models/6_20260120222211_add_workspace_avatar.py new file mode 100644 index 0000000..504819f --- /dev/null +++ b/migrations/models/6_20260120222211_add_workspace_avatar.py @@ -0,0 +1,92 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "workspace" ADD "avatar_s3_key" VARCHAR(512);""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "workspace" DROP COLUMN "avatar_s3_key";""" + + +MODELS_STATE = ( + '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' + 'slSFZHCb7lLC/IeXX/8CYMWYuQ==' +) diff --git a/src/adapter/s3.py b/src/adapter/s3.py index 9ed98a1..9b45e8d 100644 --- a/src/adapter/s3.py +++ b/src/adapter/s3.py @@ -16,6 +16,7 @@ class S3Config(BaseModel): SECRET_ACCESS_KEY: str BUCKET_NAME: str REGION: str = 'us-east-1' + PUBLIC_BASE_URL: str | None = None class S3(S3Storage): @@ -103,3 +104,9 @@ class S3(S3Storage): ) as client: await client.delete_object(Bucket=self.config.BUCKET_NAME, Key=key) log.info(f'Deleted file from S3: {key}') + + def public_url(self, key: str) -> str: + base_url = self.config.PUBLIC_BASE_URL + if not base_url: + base_url = f'{self.config.ENDPOINT_URL.rstrip("/")}/{self.config.BUCKET_NAME}' + return f'{base_url.rstrip("/")}/{key.lstrip("/")}' diff --git a/src/adapter/telegram_bot.py b/src/adapter/telegram_bot.py index 6698c83..30b0db3 100644 --- a/src/adapter/telegram_bot.py +++ b/src/adapter/telegram_bot.py @@ -9,18 +9,77 @@ log = logging.getLogger(__name__) 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( + self, text: str, chat_id: int, parse_mode: str | None = None, disable_preview: bool = False + ) -> None: + await self.bot.send_message( + chat_id=chat_id, text=text, parse_mode=parse_mode, disable_web_page_preview=disable_preview + ) 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 async def send_message_with_inline_keyboard( - self, text: str, chat_id: int, buttons: list[list[InlineKeyboardButton]] + self, + text: str, + chat_id: int, + buttons: list[list[InlineKeyboardButton]], + parse_mode: str | None = None, + disable_preview: bool = False, ) -> None: keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) - await self.bot.send_message(chat_id=chat_id, text=text, reply_markup=keyboard) + await self.bot.send_message( + chat_id=chat_id, + text=text, + reply_markup=keyboard, + parse_mode=parse_mode, + disable_web_page_preview=disable_preview, + ) + + async def send_media_with_inline_keyboard( + self, + text: str, + chat_id: int, + media_type: str, + media_file_id: str, + buttons: list[list[InlineKeyboardButton]], + parse_mode: str | None = None, + ) -> None: + keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) if buttons else None + if media_type == 'photo': + await self.bot.send_photo( + chat_id=chat_id, + photo=media_file_id, + caption=text, + parse_mode=parse_mode, + reply_markup=keyboard, + ) + return + if media_type == 'video': + await self.bot.send_video( + chat_id=chat_id, + video=media_file_id, + caption=text, + parse_mode=parse_mode, + reply_markup=keyboard, + ) + return + if media_type == 'animation': + await self.bot.send_animation( + chat_id=chat_id, + animation=media_file_id, + caption=text, + parse_mode=parse_mode, + reply_markup=keyboard, + ) + return + await self.bot.send_message( + chat_id=chat_id, + text=text, + parse_mode=parse_mode, + reply_markup=keyboard, + ) 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/controller/http_v1/internal.py b/src/controller/http_v1/internal.py index 458a9ab..f53f001 100644 --- a/src/controller/http_v1/internal.py +++ b/src/controller/http_v1/internal.py @@ -1,4 +1,4 @@ -from typing import Annotated, Literal, Union +from typing import Annotated, Literal from fastapi.routing import APIRouter from pydantic import BaseModel, Field @@ -86,13 +86,11 @@ class BotPermissionsEventRequest(dto.UpdateProjectPermissionsInput): EventRequest = Annotated[ - Union[ - SubscriptionEventRequest, - UnsubscriptionEventRequest, - BotAddedEventRequest, - BotRemovedEventRequest, - BotPermissionsEventRequest, - ], + SubscriptionEventRequest + | UnsubscriptionEventRequest + | BotAddedEventRequest + | BotRemovedEventRequest + | BotPermissionsEventRequest, Field(discriminator='type'), ] @@ -117,15 +115,15 @@ async def handle_event(input: EventRequest) -> None: return if isinstance(input, BotAddedEventRequest): - data = dto.ConnectProjectInput(**input.dict(exclude={'type'})) - await deps.get_usecase().tg_add_project(input=data) + add_data = dto.ConnectProjectInput(**input.dict(exclude={'type'})) + await deps.get_usecase().tg_add_project(input=add_data) return if isinstance(input, BotRemovedEventRequest): - data = dto.DisconnectProjectByTgIdInput(**input.dict(exclude={'type'})) - await deps.get_usecase().disconnect_project_by_tg_id(input=data) + remove_data = dto.DisconnectProjectByTgIdInput(**input.dict(exclude={'type'})) + await deps.get_usecase().disconnect_project_by_tg_id(input=remove_data) return if isinstance(input, BotPermissionsEventRequest): - data = dto.UpdateProjectPermissionsInput(**input.dict(exclude={'type'})) - await deps.get_usecase().update_project_permissions(input=data) + permissions_data = dto.UpdateProjectPermissionsInput(**input.dict(exclude={'type'})) + await deps.get_usecase().update_project_permissions(input=permissions_data) diff --git a/src/controller/http_v1/workspaces.py b/src/controller/http_v1/workspaces.py index babcb01..7377a01 100644 --- a/src/controller/http_v1/workspaces.py +++ b/src/controller/http_v1/workspaces.py @@ -1,7 +1,7 @@ import uuid from typing import Annotated -from fastapi import Depends +from fastapi import Depends, File, UploadFile from fastapi.routing import APIRouter from fastapi_pagination import Page, paginate @@ -42,3 +42,26 @@ async def delete_workspace( current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], ) -> None: await deps.get_usecase().delete_workspace(workspace_id, current_user.user_id) + + +@workspaces_router.post('/{workspace_id}/avatar') +async def upload_workspace_avatar( + workspace_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], + file: UploadFile = File(...), +) -> dto.WorkspaceMembershipOutput: + avatar_data = await file.read() + return await deps.get_usecase().update_workspace_avatar( + workspace_id, + current_user.user_id, + avatar_data, + file.content_type, + ) + + +@workspaces_router.delete('/{workspace_id}/avatar') +async def delete_workspace_avatar( + workspace_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.WorkspaceMembershipOutput: + return await deps.get_usecase().delete_workspace_avatar(workspace_id, current_user.user_id) diff --git a/src/domain/__init__.py b/src/domain/__init__.py index ef68810..9bc3fc9 100644 --- a/src/domain/__init__.py +++ b/src/domain/__init__.py @@ -24,6 +24,8 @@ __all__ = ( 'replace_invite_link_with_tag', 'validate_media_size', 'MAX_CREATIVE_MEDIA_BYTES', + 'validate_workspace_avatar_size', + 'MAX_WORKSPACE_AVATAR_BYTES', 'Post', 'Subscription', 'PostViewsHistory', @@ -41,6 +43,7 @@ __all__ = ( 'WorkspaceInviteAlreadyExists', 'WorkspaceInviteAlreadyProcessed', 'WorkspaceMemberAlreadyExists', + 'WorkspaceAvatarTooLarge', 'LoginTokenNotFound', 'LoginTokenExpired', 'LoginTokenAlreadyUsed', @@ -48,6 +51,7 @@ __all__ = ( 'ProjectChannelConflict', 'PlacementNotFound', 'PlacementPostNotFound', + 'PlacementHasPosts', 'ChannelNotFound', 'ChannelAlreadyExists', 'ChannelNoAdminRights', @@ -80,6 +84,7 @@ from .error import ( LoginTokenAlreadyUsed, LoginTokenExpired, LoginTokenNotFound, + PlacementHasPosts, PlacementNotFound, PlacementPostNotFound, ProjectChannelConflict, @@ -88,6 +93,7 @@ from .error import ( UserByUsernameNotFound, UserNotFound, WorkspaceAccessDenied, + WorkspaceAvatarTooLarge, WorkspaceInviteAlreadyExists, WorkspaceInviteAlreadyProcessed, WorkspaceInviteNotFound, @@ -110,6 +116,7 @@ from .subscription import Subscription, SubscriptionStatus from .telegram_user import TelegramUser from .user import User from .workspace import ( + MAX_WORKSPACE_AVATAR_BYTES, PermissionKey, PermissionScopeType, Workspace, @@ -119,6 +126,7 @@ from .workspace import ( WorkspaceUserPermission, WorkspaceUserPermissionScope, WorkspaceUserStatus, + validate_workspace_avatar_size, ) from .workspace_permissions import ( WorkspacePermissionContext, diff --git a/src/domain/creative.py b/src/domain/creative.py index 277cb85..5d7e5d4 100644 --- a/src/domain/creative.py +++ b/src/domain/creative.py @@ -40,7 +40,10 @@ class Creative(TimestampedModel): MAX_CREATIVE_MEDIA_BYTES = 20 * 1024 * 1024 -_INVITE_LINK_HTML = re.compile(r'([^<]*)', re.IGNORECASE) +_INVITE_LINK_HTML = re.compile( + r'([^<]*)', + re.IGNORECASE, +) _INVITE_LINK_PLAIN = re.compile(r'https?://t\.me/(?:\+|joinchat/)[a-zA-Z0-9_-]+', re.IGNORECASE) _INVITE_LINK_REPLACED = re.compile(r'([^<]*)', re.IGNORECASE) _INVITE_LINK_REPLACED_URL = re.compile( @@ -72,7 +75,8 @@ def replace_invite_link_with_tag(text: str) -> str: return _INVITE_LINK_REPLACED_URL.sub('', text) if html_count == 1: - def _replace_html(match: re.Match) -> str: + + def _replace_html(match: re.Match[str]) -> str: inner = match.group(1).strip() if _INVITE_LINK_PLAIN.fullmatch(inner): return '' diff --git a/src/domain/error.py b/src/domain/error.py index a07fdec..320d040 100644 --- a/src/domain/error.py +++ b/src/domain/error.py @@ -129,3 +129,7 @@ def WorkspaceInviteAlreadyProcessed() -> HTTPException: def WorkspaceMemberAlreadyExists() -> HTTPException: return HTTPException(status.HTTP_409_CONFLICT, 'User is already a workspace member') + + +def WorkspaceAvatarTooLarge(max_bytes: int) -> HTTPException: + return HTTPException(status.HTTP_400_BAD_REQUEST, f'Workspace avatar is too large (max {max_bytes} bytes)') diff --git a/src/domain/placement.py b/src/domain/placement.py index a44adc1..d7ef224 100644 --- a/src/domain/placement.py +++ b/src/domain/placement.py @@ -59,7 +59,7 @@ class Placement(TimestampedModel): project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField( 'models.Project', related_name='placements', on_delete=fields.CASCADE, index=True ) - creative: fields.ForeignKeyRelation['Creative'] = fields.ForeignKeyField( + creative: fields.ForeignKeyRelation['Creative'] | None = fields.ForeignKeyField( 'models.Creative', related_name='placements', on_delete=fields.CASCADE, null=True, index=True ) channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField( diff --git a/src/domain/workspace.py b/src/domain/workspace.py index 87e4854..0792255 100644 --- a/src/domain/workspace.py +++ b/src/domain/workspace.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING from tortoise import fields from .base import TimestampedModel +from .error import WorkspaceAvatarTooLarge if TYPE_CHECKING: from .channel import Channel @@ -19,6 +20,7 @@ if TYPE_CHECKING: class Workspace(TimestampedModel): id = fields.UUIDField(pk=True) name = fields.CharField(max_length=255) + avatar_s3_key = fields.CharField(max_length=512, null=True) class Meta: table = 'workspace' @@ -149,3 +151,13 @@ class WorkspaceUserPermissionScope(TimestampedModel): class Meta: table = 'workspace_user_permission_scope' + + +MAX_WORKSPACE_AVATAR_BYTES = 5 * 1024 * 1024 + + +def validate_workspace_avatar_size(avatar_data: bytes | None) -> None: + if avatar_data is None: + return + if len(avatar_data) > MAX_WORKSPACE_AVATAR_BYTES: + raise WorkspaceAvatarTooLarge(MAX_WORKSPACE_AVATAR_BYTES) diff --git a/src/dto/__init__.py b/src/dto/__init__.py index 8c18ec6..1904b61 100644 --- a/src/dto/__init__.py +++ b/src/dto/__init__.py @@ -158,8 +158,8 @@ from .purchase import ( PlacementDetails, PlacementOutput, PlacementPostOutput, - PostOutput, PlacementWithPostsOutput, + PostOutput, UpdatePlacementInput, ) from .user import UserOutput diff --git a/src/dto/purchase.py b/src/dto/purchase.py index 5d6e0c7..b77b4f6 100644 --- a/src/dto/purchase.py +++ b/src/dto/purchase.py @@ -6,7 +6,6 @@ import pydantic from src.domain.placement import CostType, InviteLinkType, PlacementStatus, PlacementType from .channel import ChannelOutput -from .creative import CreativePreviewOutput class CostInfo(pydantic.BaseModel): @@ -57,8 +56,6 @@ class PlacementWithPostsOutput(PlacementOutput): placement_post: PlacementPostOutput | None = None - - class CreatePlacementChannelInput(pydantic.BaseModel): """Input для создания одного placement (channel + детали)""" diff --git a/src/dto/workspace.py b/src/dto/workspace.py index 03522ad..96fb671 100644 --- a/src/dto/workspace.py +++ b/src/dto/workspace.py @@ -8,6 +8,7 @@ from src import domain class WorkspaceMembershipOutput(pydantic.BaseModel): id: uuid.UUID name: str + avatar_url: str | None = None class CreateWorkspaceOutput(WorkspaceMembershipOutput): ... diff --git a/src/usecase/__init__.py b/src/usecase/__init__.py index f9cf486..c73ca9d 100644 --- a/src/usecase/__init__.py +++ b/src/usecase/__init__.py @@ -51,19 +51,38 @@ from .workspace.accept_workspace_invite import accept_workspace_invite from .workspace.create_workspace import create_workspace from .workspace.create_workspace_invite import create_workspace_invite from .workspace.delete_workspace import delete_workspace +from .workspace.delete_workspace_avatar import delete_workspace_avatar from .workspace.get_workspace_invites import get_workspace_invites from .workspace.get_workspace_members import get_workspace_members from .workspace.get_workspaces import get_workspaces from .workspace.tg_accept_workspace_invite import tg_accept_workspace_invite from .workspace.update_workspace import update_workspace +from .workspace.update_workspace_avatar import update_workspace_avatar from .workspace.update_workspace_member_permissions import update_workspace_member_permissions class TelegramBotWriter(typing.Protocol): - async def send_message(self, text: str, chat_id: int) -> None: ... + async def send_message( + self, text: str, chat_id: int, parse_mode: str | None = None, disable_preview: bool = False + ) -> None: ... async def send_message_with_inline_keyboard( - self, text: str, chat_id: int, buttons: list[list['InlineKeyboardButton']] + self, + text: str, + chat_id: int, + buttons: list[list['InlineKeyboardButton']], + parse_mode: str | None = None, + disable_preview: bool = False, + ) -> None: ... + + async def send_media_with_inline_keyboard( + self, + text: str, + chat_id: int, + media_type: str, + media_file_id: str, + buttons: list[list['InlineKeyboardButton']], + parse_mode: str | None = None, ) -> None: ... async def edit_message_text(self, text: str, chat_id: int, message_id: int) -> None: ... @@ -94,6 +113,8 @@ class S3Storage(typing.Protocol): async def delete(self, key: str) -> None: ... + def public_url(self, key: str) -> str: ... + @dataclass class Usecase: @@ -194,6 +215,8 @@ class Usecase: create_workspace = create_workspace update_workspace = update_workspace delete_workspace = delete_workspace + update_workspace_avatar = update_workspace_avatar + delete_workspace_avatar = delete_workspace_avatar get_workspace_members = get_workspace_members update_workspace_member_permissions = update_workspace_member_permissions create_workspace_invite = create_workspace_invite diff --git a/src/usecase/auth/validate_login_token.py b/src/usecase/auth/validate_login_token.py index 3cf0e89..de7fc8d 100644 --- a/src/usecase/auth/validate_login_token.py +++ b/src/usecase/auth/validate_login_token.py @@ -39,7 +39,7 @@ async def validate_login_token(self: 'Usecase', input: dto.ValidateLoginTokenInp message_id=login_token.message_id, ) except Exception: - logging.getLogger(__name__).exception("Failed to update login message") + logging.getLogger(__name__).exception('Failed to update login message') access_token = self.jwt_encoder.encode_access_token( user_id=user.id, diff --git a/src/usecase/purchase/build_placement_creative.py b/src/usecase/purchase/build_placement_creative.py index d8c58a7..5aad845 100644 --- a/src/usecase/purchase/build_placement_creative.py +++ b/src/usecase/purchase/build_placement_creative.py @@ -3,6 +3,8 @@ import re import uuid from typing import TYPE_CHECKING +from aiogram.types import InlineKeyboardButton + from src import domain, dto if TYPE_CHECKING: @@ -18,7 +20,7 @@ def _inject_invite_link(text: str, invite_link: str) -> str: if not text: return text - def _replace(match: re.Match) -> str: + def _replace(match: re.Match[str]) -> str: inner = match.group(1).strip() if inner: return f'{inner}' @@ -27,7 +29,7 @@ def _inject_invite_link(text: str, invite_link: str) -> str: return _INVITE_LINK_TAG.sub(_replace, text) -def _build_buttons(buttons: list[dict], invite_link: str) -> list[dto.CreativeButton]: +def _build_buttons(buttons: list[dict[str, str]], invite_link: str) -> list[dto.CreativeButton]: result: list[dto.CreativeButton] = [] for raw in buttons: text = raw.get('text') @@ -40,6 +42,18 @@ def _build_buttons(buttons: list[dict], invite_link: str) -> list[dto.CreativeBu return result +def _format_channel_name(channel: domain.Channel) -> str: + if channel.title: + return channel.title + if channel.username: + return f'@{channel.username}' + return 'Без названия' + + +def _build_keyboard_buttons(buttons: list[dto.CreativeButton]) -> list[list[InlineKeyboardButton]]: + return [[InlineKeyboardButton(text=btn.text, url=btn.url)] for btn in buttons] + + async def build_placement_creative( self: 'Usecase', placement_id: uuid.UUID, @@ -47,9 +61,7 @@ async def build_placement_creative( project_id: uuid.UUID, user_id: uuid.UUID, ) -> dto.CreativePreviewOutput: - context = await self.ensure_workspace_permission( - workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE - ) + context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE) project = await self.database.get_project(workspace_id, project_id=project_id) if not project: @@ -89,4 +101,35 @@ async def build_placement_creative( media_file_id=creative.media_file_id, buttons=_build_buttons(creative.buttons, invite_link), ) + + user = await self.database.get_user(user_id=user_id) + if not user or not user.telegram_user: + raise domain.UserNotFound(user_id) + + chat_id = user.telegram_user.telegram_id + channel_name = _format_channel_name(placement.channel) + header = f'Канал: {channel_name}' + 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', + ) + elif keyboard_buttons: + await self.telegram_bot.send_message_with_inline_keyboard( + preview.text, + chat_id, + keyboard_buttons, + parse_mode='HTML', + disable_preview=True, + ) + else: + await self.telegram_bot.send_message(preview.text, chat_id, parse_mode='HTML', disable_preview=True) + return preview diff --git a/src/usecase/purchase/create_placements.py b/src/usecase/purchase/create_placements.py index 8413380..cd1690a 100644 --- a/src/usecase/purchase/create_placements.py +++ b/src/usecase/purchase/create_placements.py @@ -97,8 +97,10 @@ async def create_placements( # Объединяем общие детали с деталями конкретного канала channel_details = channel_input.details creative_id = ( - channel_details.creative_id if channel_details and channel_details.creative_id else None - ) or (details.creative_id if details and details.creative_id else None) or input.creative_id + (channel_details.creative_id if channel_details and channel_details.creative_id else None) + or (details.creative_id if details and details.creative_id else None) + or input.creative_id + ) if not creative_id: raise domain.CreativeNotFound() creative = creatives_by_id.get(creative_id) diff --git a/src/usecase/purchase/get_placements.py b/src/usecase/purchase/get_placements.py index b75603a..a8deabb 100644 --- a/src/usecase/purchase/get_placements.py +++ b/src/usecase/purchase/get_placements.py @@ -61,7 +61,9 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto. if placement_posts_for_placement: if len(placement_posts_for_placement) > 1: log.warning( - 'Placement %s has %s placement_posts, returning latest', placement.id, len(placement_posts_for_placement) + 'Placement %s has %s placement_posts, returning latest', + placement.id, + len(placement_posts_for_placement), ) placement_post_output = placement_posts_for_placement[0] placement_outputs.append( diff --git a/src/usecase/purchase/update_placement.py b/src/usecase/purchase/update_placement.py index 8c2f593..77a9953 100644 --- a/src/usecase/purchase/update_placement.py +++ b/src/usecase/purchase/update_placement.py @@ -18,9 +18,7 @@ async def update_placement( project_id: uuid.UUID, user_id: uuid.UUID, ) -> dto.PlacementWithPostsOutput: - context = await self.ensure_workspace_permission( - workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE - ) + context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE) project = await self.database.get_project(workspace_id, project_id=project_id) if not project: diff --git a/src/usecase/workspace/create_workspace.py b/src/usecase/workspace/create_workspace.py index cf429a2..7974571 100644 --- a/src/usecase/workspace/create_workspace.py +++ b/src/usecase/workspace/create_workspace.py @@ -30,4 +30,5 @@ async def create_workspace( return dto.CreateWorkspaceOutput( id=workspace.id, name=workspace.name, + avatar_url=self.s3.public_url(workspace.avatar_s3_key) if workspace.avatar_s3_key else None, ) diff --git a/src/usecase/workspace/delete_workspace_avatar.py b/src/usecase/workspace/delete_workspace_avatar.py new file mode 100644 index 0000000..c85a6e7 --- /dev/null +++ b/src/usecase/workspace/delete_workspace_avatar.py @@ -0,0 +1,42 @@ +import asyncio +import logging +import uuid +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def delete_workspace_avatar( + self: 'Usecase', workspace_id: uuid.UUID, user_id: uuid.UUID +) -> dto.WorkspaceMembershipOutput: + await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.ADMIN_FULL) + + membership = await self.database.get_workspace_membership(workspace_id, user_id) + if not membership or not membership.workspace: + raise domain.WorkspaceNotFound(workspace_id) + + workspace = membership.workspace + old_key = workspace.avatar_s3_key + if old_key: + workspace.avatar_s3_key = None + await self.database.update_workspace(workspace) + + async def delete_old_avatar() -> None: + try: + await self.s3.delete(old_key) + log.info('Deleted workspace avatar from S3: %s', old_key) + except Exception as exc: + log.warning('Failed to delete workspace avatar from S3: %s', exc) + + asyncio.create_task(delete_old_avatar()) + + return dto.WorkspaceMembershipOutput( + id=workspace.id, + name=workspace.name, + avatar_url=None, + ) diff --git a/src/usecase/workspace/get_workspaces.py b/src/usecase/workspace/get_workspaces.py index b7c7264..89195f8 100644 --- a/src/usecase/workspace/get_workspaces.py +++ b/src/usecase/workspace/get_workspaces.py @@ -14,6 +14,13 @@ async def get_workspaces(self: 'Usecase', user_id: uuid.UUID) -> list[dto.Worksp dto.WorkspaceMembershipOutput( id=membership.workspace_id, name=membership.workspace.name, + avatar_url=_build_avatar_url(self, membership.workspace.avatar_s3_key), ) for membership in memberships ] + + +def _build_avatar_url(self: 'Usecase', avatar_key: str | None) -> str | None: + if not avatar_key: + return None + return self.s3.public_url(avatar_key) diff --git a/src/usecase/workspace/update_workspace.py b/src/usecase/workspace/update_workspace.py index 3783195..e2bdafd 100644 --- a/src/usecase/workspace/update_workspace.py +++ b/src/usecase/workspace/update_workspace.py @@ -25,4 +25,5 @@ async def update_workspace( return dto.WorkspaceMembershipOutput( id=workspace.id, name=workspace.name, + avatar_url=self.s3.public_url(workspace.avatar_s3_key) if workspace.avatar_s3_key else None, ) diff --git a/src/usecase/workspace/update_workspace_avatar.py b/src/usecase/workspace/update_workspace_avatar.py new file mode 100644 index 0000000..3289800 --- /dev/null +++ b/src/usecase/workspace/update_workspace_avatar.py @@ -0,0 +1,53 @@ +import asyncio +import logging +import uuid +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def update_workspace_avatar( + self: 'Usecase', + workspace_id: uuid.UUID, + user_id: uuid.UUID, + avatar_data: bytes, + content_type: str | None, +) -> dto.WorkspaceMembershipOutput: + await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.ADMIN_FULL) + + membership = await self.database.get_workspace_membership(workspace_id, user_id) + if not membership or not membership.workspace: + raise domain.WorkspaceNotFound(workspace_id) + + domain.validate_workspace_avatar_size(avatar_data) + + file_id = uuid.uuid4() + avatar_key = f'workspaces/{workspace_id}/avatars/{file_id}' + await self.s3.upload(avatar_key, avatar_data, content_type or 'application/octet-stream') + + workspace = membership.workspace + old_key = workspace.avatar_s3_key + workspace.avatar_s3_key = avatar_key + await self.database.update_workspace(workspace) + + if old_key: + + async def delete_old_avatar() -> None: + try: + await self.s3.delete(old_key) + log.info('Deleted old workspace avatar from S3: %s', old_key) + except Exception as exc: + log.warning('Failed to delete old workspace avatar from S3: %s', exc) + + asyncio.create_task(delete_old_avatar()) + + return dto.WorkspaceMembershipOutput( + id=workspace.id, + name=workspace.name, + avatar_url=self.s3.public_url(avatar_key), + ) diff --git a/tg_bot/screens/purchase_optional_details.go b/tg_bot/screens/purchase_optional_details.go index f42a56d..629f187 100644 --- a/tg_bot/screens/purchase_optional_details.go +++ b/tg_bot/screens/purchase_optional_details.go @@ -1439,7 +1439,7 @@ func (s *PurchaseOptionalDetails) createPurchase(b *bot.Bot, jwt string) { } for _, placement := range placements.Placements { - creativePreview, err := b.Backend.BuildPlacementCreative( + _, err := b.Backend.BuildPlacementCreative( context.Background(), jwt, b.Session.WorkspaceID, @@ -1450,12 +1450,6 @@ func (s *PurchaseOptionalDetails) createPurchase(b *bot.Bot, jwt string) { log.Error().Err(err).Msg("Failed to build placement creative") continue } - - channelName := s.formatChannelName(placement) - header := fmt.Sprintf("Канал: %s", channelName) - b.SendMessage(header, b.ChatID, &echotron.MessageOptions{ParseMode: echotron.HTML}) - - s.sendCreativePreview(b, creativePreview) } b.SetState(&Placements{ @@ -1580,36 +1574,3 @@ func (s *PurchaseOptionalDetails) normalizePurchaseType() (*string, bool) { return nil, false } } - -func (s *PurchaseOptionalDetails) formatChannelName(placement backend.PlacementOutput) string { - if placement.Channel.Title != nil && *placement.Channel.Title != "" { - return *placement.Channel.Title - } - if placement.Channel.Username != nil && *placement.Channel.Username != "" { - return "@" + *placement.Channel.Username - } - return "Без названия" -} - -func (s *PurchaseOptionalDetails) sendCreativePreview( - b *bot.Bot, - creative *backend.CreativePreviewOutput, -) { - editor := CreativeEditorFields{ - Text: &creative.Text, - MediaType: creative.MediaType, - MediaFileID: creative.MediaFileID, - } - - if len(creative.Buttons) > 0 { - editor.Buttons = make([]InlineButton, 0, len(creative.Buttons)) - for _, btn := range creative.Buttons { - editor.Buttons = append(editor.Buttons, InlineButton{ - Text: btn.Text, - URL: btn.URL, - }) - } - } - - editor.SendCreativePreview(b) -}