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 from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True RUN_IN_TRANSACTION = True
@@ -32,75 +33,75 @@ async def downgrade(db: BaseDBAsyncClient) -> str:
MODELS_STATE = ( MODELS_STATE = (
"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'
"f/C4NtvdBk9VSAWNZJcSZj+9vP8f6Gvh+w==" '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 SECRET_ACCESS_KEY: str
BUCKET_NAME: str BUCKET_NAME: str
REGION: str = 'us-east-1' REGION: str = 'us-east-1'
PUBLIC_BASE_URL: str | None = None
class S3(S3Storage): class S3(S3Storage):
@@ -103,3 +104,9 @@ class S3(S3Storage):
) as client: ) as client:
await client.delete_object(Bucket=self.config.BUCKET_NAME, Key=key) await client.delete_object(Bucket=self.config.BUCKET_NAME, Key=key)
log.info(f'Deleted file from S3: {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): class TelegramBot(TelegramBase, TelegramBotWriter):
async def send_message(self, text: str, chat_id: int) -> None: async def send_message(
await self.bot.send_message(chat_id=chat_id, text=text) 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: 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) invite_link = await self.bot.create_chat_invite_link(chat_id=chat_id, creates_join_request=requires_approval)
return invite_link.invite_link return invite_link.invite_link
async def send_message_with_inline_keyboard( 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: ) -> None:
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) 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: 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) 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 fastapi.routing import APIRouter
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -86,13 +86,11 @@ class BotPermissionsEventRequest(dto.UpdateProjectPermissionsInput):
EventRequest = Annotated[ EventRequest = Annotated[
Union[ SubscriptionEventRequest
SubscriptionEventRequest, | UnsubscriptionEventRequest
UnsubscriptionEventRequest, | BotAddedEventRequest
BotAddedEventRequest, | BotRemovedEventRequest
BotRemovedEventRequest, | BotPermissionsEventRequest,
BotPermissionsEventRequest,
],
Field(discriminator='type'), Field(discriminator='type'),
] ]
@@ -117,15 +115,15 @@ async def handle_event(input: EventRequest) -> None:
return return
if isinstance(input, BotAddedEventRequest): if isinstance(input, BotAddedEventRequest):
data = dto.ConnectProjectInput(**input.dict(exclude={'type'})) add_data = dto.ConnectProjectInput(**input.dict(exclude={'type'}))
await deps.get_usecase().tg_add_project(input=data) await deps.get_usecase().tg_add_project(input=add_data)
return return
if isinstance(input, BotRemovedEventRequest): if isinstance(input, BotRemovedEventRequest):
data = dto.DisconnectProjectByTgIdInput(**input.dict(exclude={'type'})) remove_data = dto.DisconnectProjectByTgIdInput(**input.dict(exclude={'type'}))
await deps.get_usecase().disconnect_project_by_tg_id(input=data) await deps.get_usecase().disconnect_project_by_tg_id(input=remove_data)
return return
if isinstance(input, BotPermissionsEventRequest): if isinstance(input, BotPermissionsEventRequest):
data = dto.UpdateProjectPermissionsInput(**input.dict(exclude={'type'})) permissions_data = dto.UpdateProjectPermissionsInput(**input.dict(exclude={'type'}))
await deps.get_usecase().update_project_permissions(input=data) await deps.get_usecase().update_project_permissions(input=permissions_data)

View File

@@ -1,7 +1,7 @@
import uuid import uuid
from typing import Annotated from typing import Annotated
from fastapi import Depends from fastapi import Depends, File, UploadFile
from fastapi.routing import APIRouter from fastapi.routing import APIRouter
from fastapi_pagination import Page, paginate from fastapi_pagination import Page, paginate
@@ -42,3 +42,26 @@ async def delete_workspace(
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> None: ) -> None:
await deps.get_usecase().delete_workspace(workspace_id, current_user.user_id) 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', 'replace_invite_link_with_tag',
'validate_media_size', 'validate_media_size',
'MAX_CREATIVE_MEDIA_BYTES', 'MAX_CREATIVE_MEDIA_BYTES',
'validate_workspace_avatar_size',
'MAX_WORKSPACE_AVATAR_BYTES',
'Post', 'Post',
'Subscription', 'Subscription',
'PostViewsHistory', 'PostViewsHistory',
@@ -41,6 +43,7 @@ __all__ = (
'WorkspaceInviteAlreadyExists', 'WorkspaceInviteAlreadyExists',
'WorkspaceInviteAlreadyProcessed', 'WorkspaceInviteAlreadyProcessed',
'WorkspaceMemberAlreadyExists', 'WorkspaceMemberAlreadyExists',
'WorkspaceAvatarTooLarge',
'LoginTokenNotFound', 'LoginTokenNotFound',
'LoginTokenExpired', 'LoginTokenExpired',
'LoginTokenAlreadyUsed', 'LoginTokenAlreadyUsed',
@@ -48,6 +51,7 @@ __all__ = (
'ProjectChannelConflict', 'ProjectChannelConflict',
'PlacementNotFound', 'PlacementNotFound',
'PlacementPostNotFound', 'PlacementPostNotFound',
'PlacementHasPosts',
'ChannelNotFound', 'ChannelNotFound',
'ChannelAlreadyExists', 'ChannelAlreadyExists',
'ChannelNoAdminRights', 'ChannelNoAdminRights',
@@ -80,6 +84,7 @@ from .error import (
LoginTokenAlreadyUsed, LoginTokenAlreadyUsed,
LoginTokenExpired, LoginTokenExpired,
LoginTokenNotFound, LoginTokenNotFound,
PlacementHasPosts,
PlacementNotFound, PlacementNotFound,
PlacementPostNotFound, PlacementPostNotFound,
ProjectChannelConflict, ProjectChannelConflict,
@@ -88,6 +93,7 @@ from .error import (
UserByUsernameNotFound, UserByUsernameNotFound,
UserNotFound, UserNotFound,
WorkspaceAccessDenied, WorkspaceAccessDenied,
WorkspaceAvatarTooLarge,
WorkspaceInviteAlreadyExists, WorkspaceInviteAlreadyExists,
WorkspaceInviteAlreadyProcessed, WorkspaceInviteAlreadyProcessed,
WorkspaceInviteNotFound, WorkspaceInviteNotFound,
@@ -110,6 +116,7 @@ from .subscription import Subscription, SubscriptionStatus
from .telegram_user import TelegramUser from .telegram_user import TelegramUser
from .user import User from .user import User
from .workspace import ( from .workspace import (
MAX_WORKSPACE_AVATAR_BYTES,
PermissionKey, PermissionKey,
PermissionScopeType, PermissionScopeType,
Workspace, Workspace,
@@ -119,6 +126,7 @@ from .workspace import (
WorkspaceUserPermission, WorkspaceUserPermission,
WorkspaceUserPermissionScope, WorkspaceUserPermissionScope,
WorkspaceUserStatus, WorkspaceUserStatus,
validate_workspace_avatar_size,
) )
from .workspace_permissions import ( from .workspace_permissions import (
WorkspacePermissionContext, WorkspacePermissionContext,

View File

@@ -40,7 +40,10 @@ class Creative(TimestampedModel):
MAX_CREATIVE_MEDIA_BYTES = 20 * 1024 * 1024 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_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 = re.compile(r'<a\s+class=["\']tg-link["\']>([^<]*)</a>', re.IGNORECASE)
_INVITE_LINK_REPLACED_URL = re.compile( _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) return _INVITE_LINK_REPLACED_URL.sub('<a class="tg-link"></a>', text)
if html_count == 1: if html_count == 1:
def _replace_html(match: re.Match) -> str:
def _replace_html(match: re.Match[str]) -> str:
inner = match.group(1).strip() inner = match.group(1).strip()
if _INVITE_LINK_PLAIN.fullmatch(inner): if _INVITE_LINK_PLAIN.fullmatch(inner):
return '<a class="tg-link"></a>' return '<a class="tg-link"></a>'

View File

@@ -129,3 +129,7 @@ def WorkspaceInviteAlreadyProcessed() -> HTTPException:
def WorkspaceMemberAlreadyExists() -> HTTPException: def WorkspaceMemberAlreadyExists() -> HTTPException:
return HTTPException(status.HTTP_409_CONFLICT, 'User is already a workspace member') 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( project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
'models.Project', related_name='placements', on_delete=fields.CASCADE, index=True '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 'models.Creative', related_name='placements', on_delete=fields.CASCADE, null=True, index=True
) )
channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField( channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField(

View File

@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING
from tortoise import fields from tortoise import fields
from .base import TimestampedModel from .base import TimestampedModel
from .error import WorkspaceAvatarTooLarge
if TYPE_CHECKING: if TYPE_CHECKING:
from .channel import Channel from .channel import Channel
@@ -19,6 +20,7 @@ if TYPE_CHECKING:
class Workspace(TimestampedModel): class Workspace(TimestampedModel):
id = fields.UUIDField(pk=True) id = fields.UUIDField(pk=True)
name = fields.CharField(max_length=255) name = fields.CharField(max_length=255)
avatar_s3_key = fields.CharField(max_length=512, null=True)
class Meta: class Meta:
table = 'workspace' table = 'workspace'
@@ -149,3 +151,13 @@ class WorkspaceUserPermissionScope(TimestampedModel):
class Meta: class Meta:
table = 'workspace_user_permission_scope' 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, PlacementDetails,
PlacementOutput, PlacementOutput,
PlacementPostOutput, PlacementPostOutput,
PostOutput,
PlacementWithPostsOutput, PlacementWithPostsOutput,
PostOutput,
UpdatePlacementInput, UpdatePlacementInput,
) )
from .user import UserOutput from .user import UserOutput

View File

@@ -6,7 +6,6 @@ import pydantic
from src.domain.placement import CostType, InviteLinkType, PlacementStatus, PlacementType from src.domain.placement import CostType, InviteLinkType, PlacementStatus, PlacementType
from .channel import ChannelOutput from .channel import ChannelOutput
from .creative import CreativePreviewOutput
class CostInfo(pydantic.BaseModel): class CostInfo(pydantic.BaseModel):
@@ -57,8 +56,6 @@ class PlacementWithPostsOutput(PlacementOutput):
placement_post: PlacementPostOutput | None = None placement_post: PlacementPostOutput | None = None
class CreatePlacementChannelInput(pydantic.BaseModel): class CreatePlacementChannelInput(pydantic.BaseModel):
"""Input для создания одного placement (channel + детали)""" """Input для создания одного placement (channel + детали)"""

View File

@@ -8,6 +8,7 @@ from src import domain
class WorkspaceMembershipOutput(pydantic.BaseModel): class WorkspaceMembershipOutput(pydantic.BaseModel):
id: uuid.UUID id: uuid.UUID
name: str name: str
avatar_url: str | None = None
class CreateWorkspaceOutput(WorkspaceMembershipOutput): ... 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 import create_workspace
from .workspace.create_workspace_invite import create_workspace_invite from .workspace.create_workspace_invite import create_workspace_invite
from .workspace.delete_workspace import delete_workspace 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_invites import get_workspace_invites
from .workspace.get_workspace_members import get_workspace_members from .workspace.get_workspace_members import get_workspace_members
from .workspace.get_workspaces import get_workspaces from .workspace.get_workspaces import get_workspaces
from .workspace.tg_accept_workspace_invite import tg_accept_workspace_invite from .workspace.tg_accept_workspace_invite import tg_accept_workspace_invite
from .workspace.update_workspace import update_workspace 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 from .workspace.update_workspace_member_permissions import update_workspace_member_permissions
class TelegramBotWriter(typing.Protocol): 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( 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: ... ) -> None: ...
async def edit_message_text(self, text: str, chat_id: int, message_id: int) -> 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: ... async def delete(self, key: str) -> None: ...
def public_url(self, key: str) -> str: ...
@dataclass @dataclass
class Usecase: class Usecase:
@@ -194,6 +215,8 @@ class Usecase:
create_workspace = create_workspace create_workspace = create_workspace
update_workspace = update_workspace update_workspace = update_workspace
delete_workspace = delete_workspace delete_workspace = delete_workspace
update_workspace_avatar = update_workspace_avatar
delete_workspace_avatar = delete_workspace_avatar
get_workspace_members = get_workspace_members get_workspace_members = get_workspace_members
update_workspace_member_permissions = update_workspace_member_permissions update_workspace_member_permissions = update_workspace_member_permissions
create_workspace_invite = create_workspace_invite 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, message_id=login_token.message_id,
) )
except Exception: 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( access_token = self.jwt_encoder.encode_access_token(
user_id=user.id, user_id=user.id,

View File

@@ -3,6 +3,8 @@ import re
import uuid import uuid
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from aiogram.types import InlineKeyboardButton
from src import domain, dto from src import domain, dto
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -18,7 +20,7 @@ def _inject_invite_link(text: str, invite_link: str) -> str:
if not text: if not text:
return text return text
def _replace(match: re.Match) -> str: def _replace(match: re.Match[str]) -> str:
inner = match.group(1).strip() inner = match.group(1).strip()
if inner: if inner:
return f'<a href="{invite_link}">{inner}</a>' 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) 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] = [] result: list[dto.CreativeButton] = []
for raw in buttons: for raw in buttons:
text = raw.get('text') text = raw.get('text')
@@ -40,6 +42,18 @@ def _build_buttons(buttons: list[dict], invite_link: str) -> list[dto.CreativeBu
return result 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( async def build_placement_creative(
self: 'Usecase', self: 'Usecase',
placement_id: uuid.UUID, placement_id: uuid.UUID,
@@ -47,9 +61,7 @@ async def build_placement_creative(
project_id: uuid.UUID, project_id: uuid.UUID,
user_id: uuid.UUID, user_id: uuid.UUID,
) -> dto.CreativePreviewOutput: ) -> dto.CreativePreviewOutput:
context = await self.ensure_workspace_permission( context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE)
workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE
)
project = await self.database.get_project(workspace_id, project_id=project_id) project = await self.database.get_project(workspace_id, project_id=project_id)
if not project: if not project:
@@ -89,4 +101,35 @@ async def build_placement_creative(
media_file_id=creative.media_file_id, media_file_id=creative.media_file_id,
buttons=_build_buttons(creative.buttons, invite_link), 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 return preview

View File

@@ -97,8 +97,10 @@ async def create_placements(
# Объединяем общие детали с деталями конкретного канала # Объединяем общие детали с деталями конкретного канала
channel_details = channel_input.details channel_details = channel_input.details
creative_id = ( creative_id = (
channel_details.creative_id if channel_details and channel_details.creative_id else None (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 or (details.creative_id if details and details.creative_id else None)
or input.creative_id
)
if not creative_id: if not creative_id:
raise domain.CreativeNotFound() raise domain.CreativeNotFound()
creative = creatives_by_id.get(creative_id) 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 placement_posts_for_placement:
if len(placement_posts_for_placement) > 1: if len(placement_posts_for_placement) > 1:
log.warning( 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_post_output = placement_posts_for_placement[0]
placement_outputs.append( placement_outputs.append(

View File

@@ -18,9 +18,7 @@ async def update_placement(
project_id: uuid.UUID, project_id: uuid.UUID,
user_id: uuid.UUID, user_id: uuid.UUID,
) -> dto.PlacementWithPostsOutput: ) -> dto.PlacementWithPostsOutput:
context = await self.ensure_workspace_permission( context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE)
workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE
)
project = await self.database.get_project(workspace_id, project_id=project_id) project = await self.database.get_project(workspace_id, project_id=project_id)
if not project: if not project:

View File

@@ -30,4 +30,5 @@ async def create_workspace(
return dto.CreateWorkspaceOutput( return dto.CreateWorkspaceOutput(
id=workspace.id, id=workspace.id,
name=workspace.name, 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( dto.WorkspaceMembershipOutput(
id=membership.workspace_id, id=membership.workspace_id,
name=membership.workspace.name, name=membership.workspace.name,
avatar_url=_build_avatar_url(self, membership.workspace.avatar_s3_key),
) )
for membership in memberships 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( return dto.WorkspaceMembershipOutput(
id=workspace.id, id=workspace.id,
name=workspace.name, 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 { for _, placement := range placements.Placements {
creativePreview, err := b.Backend.BuildPlacementCreative( _, err := b.Backend.BuildPlacementCreative(
context.Background(), context.Background(),
jwt, jwt,
b.Session.WorkspaceID, 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") log.Error().Err(err).Msg("Failed to build placement creative")
continue 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{ b.SetState(&Placements{
@@ -1580,36 +1574,3 @@ func (s *PurchaseOptionalDetails) normalizePurchaseType() (*string, bool) {
return nil, false 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)
}