This commit is contained in:
Artem Tsyrulnikov
2025-12-16 15:13:33 +03:00
parent 18b30eaf7f
commit 23eb5ae13d
19 changed files with 115 additions and 126 deletions

View File

@@ -14,10 +14,7 @@ async def create_creative(
self: 'Usecase', input: dto.CreateCreativeInput, project_id: uuid.UUID, user_id: uuid.UUID, workspace_id: uuid.UUID
) -> dto.CreativeOutput:
await self.ensure_workspace_permission(
workspace_id,
user_id,
domain.PermissionKey.PROJECTS_WRITE,
for_project_id=project_id,
workspace_id, user_id, domain.PermissionKey.PROJECTS_WRITE, for_project_id=project_id
)
project = await self.database.get_project(workspace_id, project_id=project_id)
@@ -25,9 +22,11 @@ async def create_creative(
log.warning('User %s attempted to create creative for unavailable project %s', user_id, project_id)
raise domain.ProjectNotFound(project_id)
creative_text = domain.replace_invite_link_with_tag(input.text)
creative = domain.Creative(
name=input.name,
text=input.text,
text=creative_text,
status=domain.CreativeStatus.ACTIVE,
project_id=project.id,
workspace_id=workspace_id,

View File

@@ -1,22 +0,0 @@
import re
_LINK_WITH_LABEL_PATTERN: re.Pattern[str] = re.compile(r'\[(?P<label>[^\[\]]+?)\]\s*\{link\}', re.IGNORECASE)
_LINK_PLACEHOLDER_PATTERN: re.Pattern[str] = re.compile(r'\{link\}', re.IGNORECASE)
_LINK_CLASS = 'tg-link'
def _replace_label_placeholder(match: re.Match[str]) -> str:
label = match.group('label')
return f'<a class="{_LINK_CLASS}">{label}</a>'
def prepare_creative_text_html(source: str) -> str:
"""Normalize Telegram HTML and replace {link} placeholder with custom elements.
Supported patterns:
- ``{link}`` → ``<a class="tg-link"></a>``
- ``[Label]{link}`` → ``<a class="tg-link">Label</a>``
"""
with_labels = _LINK_WITH_LABEL_PATTERN.sub(_replace_label_placeholder, source)
return _LINK_PLACEHOLDER_PATTERN.sub(f'<a class="{_LINK_CLASS}"></a>', with_labels)

View File

@@ -4,8 +4,6 @@ from typing import TYPE_CHECKING
from src import domain
from .text_formatting import prepare_creative_text_html
if TYPE_CHECKING:
from .. import Usecase
@@ -32,8 +30,8 @@ async def tg_create_creative_4(
await self.telegram_bot.send_message(
'✅ Название сохранено!\n\n'
'💬 Теперь отправьте или перешлите текст креатива.\n'
'Используйте {link} для обозначения места вставки ссылки на ваш канал.\n'
'Вы также можете указать текст ссылки в формате [текст]{link}.',
'Пригласительная ссылка на ваш канал будет автоматически '
'заменена на уникальную ссылку для отслеживания.',
chat_id,
)
@@ -85,7 +83,7 @@ async def tg_create_creative_4(
return
message_html = text_html or text
creative_text = prepare_creative_text_html(message_html)
creative_text = domain.replace_invite_link_with_tag(message_html)
creative = domain.Creative(
name=creative_name,

View File

@@ -29,7 +29,7 @@ async def update_creative(
if input.name:
creative.name = input.name
if input.text:
creative.text = input.text
creative.text = domain.replace_invite_link_with_tag(input.text)
if input.status:
creative.status = input.status

View File

@@ -3,8 +3,6 @@ from typing import TYPE_CHECKING
from src import domain, dto
from .helpers import generate_post_url
if TYPE_CHECKING:
from .. import Usecase
@@ -23,11 +21,7 @@ async def get_placement(self: 'Usecase', input: dto.GetPlacementInput) -> dto.Pl
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, placement.project_id)
# Compute post-related fields
ad_post_url = None
if placement.post:
ad_post_url = generate_post_url(placement.post.channel.username, placement.post.message_id)
ad_post_url = placement.post.url if placement.post else None
subscriptions_count = await self.database.count_subscriptions_by_placement(placement.id)

View File

@@ -2,8 +2,6 @@ from typing import TYPE_CHECKING
from src import domain, dto
from .helpers import generate_post_url
if TYPE_CHECKING:
from .. import Usecase
@@ -33,10 +31,7 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> list
placement_outputs = []
for placement in placements:
ad_post_url = None
if placement.post:
ad_post_url = generate_post_url(placement.post.channel.username, placement.post.message_id)
ad_post_url = placement.post.url if placement.post else None
placement_outputs.append(
dto.PlacementOutput(

View File

@@ -1,7 +0,0 @@
def generate_post_url(channel_username: str | None, message_id: int) -> str | None:
"""Generate Telegram post URL from channel username and message ID."""
if not channel_username:
return None
# Remove @ prefix if present
username = channel_username.lstrip('@')
return f'https://t.me/{username}/{message_id}'

View File

@@ -4,8 +4,6 @@ from typing import TYPE_CHECKING
from src import domain, dto
from .helpers import generate_post_url
if TYPE_CHECKING:
from .. import Usecase
@@ -39,10 +37,7 @@ async def update_placement(
await self.database.update_placement(placement)
ad_post_url = None
if placement.post:
ad_post_url = generate_post_url(placement.post.channel.username, placement.post.message_id)
ad_post_url = placement.post.url if placement.post else None
subscriptions_count = await self.database.count_subscriptions_by_placement(placement.id)

View File

@@ -6,7 +6,6 @@ from typing import TYPE_CHECKING
from aiogram.types import InlineKeyboardButton
from src import domain, dto
from src.usecase.workspace.mappers import workspace_invite_to_dto
if TYPE_CHECKING:
from .. import Usecase
@@ -73,4 +72,4 @@ async def create_workspace_invite(
buttons=[[accept_button]],
)
return workspace_invite_to_dto(invite)
return dto.WorkspaceInviteOutput.from_domain(invite)

View File

@@ -4,7 +4,6 @@ import uuid
from typing import TYPE_CHECKING
from src import domain, dto
from src.usecase.workspace.mappers import workspace_invite_to_dto
if TYPE_CHECKING:
from .. import Usecase
@@ -17,4 +16,4 @@ async def get_workspace_invites(
invites = await self.database.get_workspace_invites(workspace_id)
return [workspace_invite_to_dto(invite) for invite in invites]
return [dto.WorkspaceInviteOutput.from_domain(invite) for invite in invites]

View File

@@ -4,7 +4,6 @@ import uuid
from typing import TYPE_CHECKING
from src import domain, dto
from src.usecase.workspace.mappers import workspace_member_to_dto
if TYPE_CHECKING:
from .. import Usecase
@@ -17,4 +16,4 @@ async def get_workspace_members(
members = await self.database.get_workspace_members(workspace_id)
return [workspace_member_to_dto(member) for member in members]
return [dto.WorkspaceMemberOutput.from_domain(member) for member in members]

View File

@@ -1,57 +0,0 @@
from __future__ import annotations
from src import domain, dto
def workspace_member_to_dto(member: domain.WorkspaceUser) -> dto.WorkspaceMemberOutput:
if member.user is None:
raise ValueError('Workspace member user relation is not loaded')
permissions_map: dict[domain.PermissionKey, list[dto.WorkspacePermissionScopeOutput]] = {}
for permission in getattr(member, 'permissions', []) or []:
permissions_map.setdefault(permission.permission, [])
for scope in getattr(member, 'permission_scopes', []) or []:
permissions_map.setdefault(scope.permission, []).append(
dto.WorkspacePermissionScopeOutput(
type=scope.scope_type,
id=scope.scope_id,
)
)
permissions_output = [
dto.WorkspacePermissionOutput(key=key, scopes=scopes)
for key, scopes in sorted(permissions_map.items(), key=lambda item: item[0].value)
]
return dto.WorkspaceMemberOutput(
id=member.id,
status=member.status,
user=dto.WorkspaceMemberUserOutput(
id=member.user.id,
telegram_id=member.user.telegram_id,
username=member.user.username,
),
permissions=permissions_output,
)
def workspace_invite_to_dto(invite: domain.WorkspaceInvite) -> dto.WorkspaceInviteOutput:
if invite.user is None or invite.invited_by is None:
raise ValueError('Workspace invite relations are not loaded')
return dto.WorkspaceInviteOutput(
id=invite.id,
status=invite.status,
user=dto.WorkspaceMemberUserOutput(
id=invite.user.id,
telegram_id=invite.user.telegram_id,
username=invite.user.username,
),
invited_by=dto.WorkspaceMemberUserOutput(
id=invite.invited_by.id,
telegram_id=invite.invited_by.telegram_id,
username=invite.invited_by.username,
),
)

View File

@@ -4,7 +4,6 @@ import uuid
from typing import TYPE_CHECKING
from src import domain, dto
from src.usecase.workspace.mappers import workspace_member_to_dto
if TYPE_CHECKING:
from .. import Usecase
@@ -43,4 +42,4 @@ async def update_workspace_member_permissions(
if not updated_member:
raise domain.WorkspaceAccessDenied(workspace_id)
return workspace_member_to_dto(updated_member)
return dto.WorkspaceMemberOutput.from_domain(updated_member)