workspace avatar and send builded creative

This commit is contained in:
Artem Tsyrulnikov
2026-01-20 22:33:31 +03:00
parent e8ff317566
commit 233afb5451
26 changed files with 490 additions and 151 deletions

View File

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

View File

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

View File

@@ -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("/")}'

View File

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

View File

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

View File

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

View File

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

View File

@@ -40,7 +40,10 @@ class Creative(TimestampedModel):
MAX_CREATIVE_MEDIA_BYTES = 20 * 1024 * 1024
_INVITE_LINK_HTML = re.compile(r'<a\s+href=["\']https?://t\.me/(?:\+|joinchat/)[a-zA-Z0-9_-]+["\']>([^<]*)</a>', re.IGNORECASE)
_INVITE_LINK_HTML = re.compile(
r'<a\s+href=["\']https?://t\.me/(?:\+|joinchat/)[a-zA-Z0-9_-]+["\']>([^<]*)</a>',
re.IGNORECASE,
)
_INVITE_LINK_PLAIN = re.compile(r'https?://t\.me/(?:\+|joinchat/)[a-zA-Z0-9_-]+', re.IGNORECASE)
_INVITE_LINK_REPLACED = re.compile(r'<a\s+class=["\']tg-link["\']>([^<]*)</a>', 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('<a class="tg-link"></a>', 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 '<a class="tg-link"></a>'

View File

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

View File

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

View File

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

View File

@@ -158,8 +158,8 @@ from .purchase import (
PlacementDetails,
PlacementOutput,
PlacementPostOutput,
PostOutput,
PlacementWithPostsOutput,
PostOutput,
UpdatePlacementInput,
)
from .user import UserOutput

View File

@@ -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 + детали)"""

View File

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

View File

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

View File

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

View File

@@ -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'<a href="{invite_link}">{inner}</a>'
@@ -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'<b>Канал:</b> {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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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("<b>Канал:</b> %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)
}