workspace avatar and send builded creative
This commit is contained in:
@@ -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("/")}'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>'
|
||||
|
||||
@@ -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)')
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -158,8 +158,8 @@ from .purchase import (
|
||||
PlacementDetails,
|
||||
PlacementOutput,
|
||||
PlacementPostOutput,
|
||||
PostOutput,
|
||||
PlacementWithPostsOutput,
|
||||
PostOutput,
|
||||
UpdatePlacementInput,
|
||||
)
|
||||
from .user import UserOutput
|
||||
|
||||
@@ -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 + детали)"""
|
||||
|
||||
|
||||
@@ -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): ...
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
42
src/usecase/workspace/delete_workspace_avatar.py
Normal file
42
src/usecase/workspace/delete_workspace_avatar.py
Normal 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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
53
src/usecase/workspace/update_workspace_avatar.py
Normal file
53
src/usecase/workspace/update_workspace_avatar.py
Normal 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),
|
||||
)
|
||||
Reference in New Issue
Block a user