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

@@ -20,6 +20,7 @@ __all__ = (
'PurchasePlanStatus', 'PurchasePlanStatus',
'PurchasePlanChannelStatus', 'PurchasePlanChannelStatus',
'Creative', 'Creative',
'replace_invite_link_with_tag',
'Placement', 'Placement',
'Post', 'Post',
'Subscription', 'Subscription',
@@ -52,17 +53,21 @@ __all__ = (
'TelegramChannelNotFound', 'TelegramChannelNotFound',
'CreativeNotFound', 'CreativeNotFound',
'CreativeInUse', 'CreativeInUse',
'CreativeInviteLinkNotFound',
'CreativeMultipleInviteLinks',
'PlacementNotFound', 'PlacementNotFound',
'UserByUsernameNotFound', 'UserByUsernameNotFound',
) )
from .channel import Channel from .channel import Channel
from .creative import Creative, CreativeStatus from .creative import Creative, CreativeStatus, replace_invite_link_with_tag
from .error import ( from .error import (
ChannelAlreadyExists, ChannelAlreadyExists,
ChannelNoAdminRights, ChannelNoAdminRights,
ChannelNotFound, ChannelNotFound,
CreativeInUse, CreativeInUse,
CreativeInviteLinkNotFound,
CreativeMultipleInviteLinks,
CreativeNotFound, CreativeNotFound,
LoginTokenAlreadyUsed, LoginTokenAlreadyUsed,
LoginTokenExpired, LoginTokenExpired,

View File

@@ -1,10 +1,12 @@
import enum import enum
import re
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from uuid import UUID from uuid import UUID
from tortoise import fields from tortoise import fields
from .base import TimestampedModel from .base import TimestampedModel
from .error import CreativeInviteLinkNotFound, CreativeMultipleInviteLinks
if TYPE_CHECKING: if TYPE_CHECKING:
from .project import Project from .project import Project
@@ -34,3 +36,24 @@ class Creative(TimestampedModel):
class Meta: class Meta:
table = 'creative' table = 'creative'
_INVITE_LINK_HTML = re.compile(r'<a\s+href=["\']https?://t\.me/\+[a-zA-Z0-9_-]+["\']>([^<]*)</a>', re.IGNORECASE)
_INVITE_LINK_PLAIN = re.compile(r'https?://t\.me/\+[a-zA-Z0-9_-]+', re.IGNORECASE)
def replace_invite_link_with_tag(text: str) -> str:
"""Replace Telegram invite link (t.me/+xxx) with tracking tag."""
html_count = len(_INVITE_LINK_HTML.findall(text))
plain_count = len(_INVITE_LINK_PLAIN.findall(text))
total = html_count + plain_count
if total == 0:
raise CreativeInviteLinkNotFound()
if total > 1:
raise CreativeMultipleInviteLinks()
if html_count == 1:
return _INVITE_LINK_HTML.sub(r'<a class="tg-link">\1</a>', text)
return _INVITE_LINK_PLAIN.sub('<a class="tg-link"></a>', text)

View File

@@ -71,6 +71,14 @@ def CreativeNotFound(creative_id: uuid.UUID | None = None) -> HTTPException:
return HTTPException(status.HTTP_404_NOT_FOUND, f'Creative {creative_id} not found') return HTTPException(status.HTTP_404_NOT_FOUND, f'Creative {creative_id} not found')
def CreativeInviteLinkNotFound() -> HTTPException:
return HTTPException(status.HTTP_400_BAD_REQUEST, 'Creative text must contain one invite link (t.me/+xxx)')
def CreativeMultipleInviteLinks() -> HTTPException:
return HTTPException(status.HTTP_400_BAD_REQUEST, 'Creative text must contain only one invite link')
def CreativeInUse(creative_id: uuid.UUID) -> HTTPException: def CreativeInUse(creative_id: uuid.UUID) -> HTTPException:
return HTTPException( return HTTPException(
status.HTTP_400_BAD_REQUEST, status.HTTP_400_BAD_REQUEST,

View File

@@ -24,3 +24,9 @@ class Post(TimestampedModel):
class Meta: class Meta:
table = 'post' table = 'post'
unique_together = (('channel_id', 'message_id'),) unique_together = (('channel_id', 'message_id'),)
@property
def url(self) -> str | None:
if not self.channel.username:
return None
return f'https://t.me/{self.channel.username}/{self.message_id}'

View File

@@ -101,10 +101,14 @@ class PermissionScopeType(enum.StrEnum):
class WorkspaceUserPermission(TimestampedModel): class WorkspaceUserPermission(TimestampedModel):
permission = fields.CharEnumField(PermissionKey)
workspace_user: fields.ForeignKeyRelation[WorkspaceUser] = fields.ForeignKeyField( workspace_user: fields.ForeignKeyRelation[WorkspaceUser] = fields.ForeignKeyField(
'models.WorkspaceUser', related_name='permissions', on_delete=fields.CASCADE, index=True 'models.WorkspaceUser', related_name='permissions', on_delete=fields.CASCADE, index=True
) )
permission = fields.CharEnumField(PermissionKey)
if TYPE_CHECKING:
workspace_user_id: uuid.UUID
class Meta: class Meta:
table = 'workspace_user_permission' table = 'workspace_user_permission'
@@ -120,6 +124,9 @@ class WorkspaceUserPermissionScope(TimestampedModel):
'models.WorkspaceUser', related_name='permission_scopes', on_delete=fields.CASCADE, index=True 'models.WorkspaceUser', related_name='permission_scopes', on_delete=fields.CASCADE, index=True
) )
if TYPE_CHECKING:
workspace_user_id: uuid.UUID
class Meta: class Meta:
table = 'workspace_user_permission_scope' table = 'workspace_user_permission_scope'
unique_together = (('workspace_user_id', 'permission', 'scope_type', 'scope_id'),) unique_together = (('workspace_user_id', 'permission', 'scope_type', 'scope_id'),)

View File

@@ -47,6 +47,35 @@ class WorkspaceMemberOutput(pydantic.BaseModel):
user: WorkspaceMemberUserOutput user: WorkspaceMemberUserOutput
permissions: list[WorkspacePermissionOutput] permissions: list[WorkspacePermissionOutput]
@classmethod
def from_domain(cls, member: domain.WorkspaceUser) -> 'WorkspaceMemberOutput':
if member.user is None:
raise ValueError('Workspace member user relation is not loaded')
permissions_map: dict[domain.PermissionKey, list[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(
WorkspacePermissionScopeOutput(type=scope.scope_type, id=scope.scope_id)
)
return cls(
id=member.id,
status=member.status,
user=WorkspaceMemberUserOutput(
id=member.user.id,
telegram_id=member.user.telegram_id,
username=member.user.username,
),
permissions=[
WorkspacePermissionOutput(key=key, scopes=scopes)
for key, scopes in sorted(permissions_map.items(), key=lambda item: item[0].value)
],
)
class GetWorkspaceMembersOutput(pydantic.BaseModel): class GetWorkspaceMembersOutput(pydantic.BaseModel):
members: list[WorkspaceMemberOutput] members: list[WorkspaceMemberOutput]
@@ -119,6 +148,26 @@ class WorkspaceInviteOutput(pydantic.BaseModel):
user: WorkspaceMemberUserOutput user: WorkspaceMemberUserOutput
invited_by: WorkspaceMemberUserOutput invited_by: WorkspaceMemberUserOutput
@classmethod
def from_domain(cls, invite: domain.WorkspaceInvite) -> 'WorkspaceInviteOutput':
if invite.user is None or invite.invited_by is None:
raise ValueError('Workspace invite relations are not loaded')
return cls(
id=invite.id,
status=invite.status,
user=WorkspaceMemberUserOutput(
id=invite.user.id,
telegram_id=invite.user.telegram_id,
username=invite.user.username,
),
invited_by=WorkspaceMemberUserOutput(
id=invite.invited_by.id,
telegram_id=invite.invited_by.telegram_id,
username=invite.invited_by.username,
),
)
class GetWorkspaceInvitesOutput(pydantic.BaseModel): class GetWorkspaceInvitesOutput(pydantic.BaseModel):
invites: list[WorkspaceInviteOutput] invites: list[WorkspaceInviteOutput]

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 self: 'Usecase', input: dto.CreateCreativeInput, project_id: uuid.UUID, user_id: uuid.UUID, workspace_id: uuid.UUID
) -> dto.CreativeOutput: ) -> dto.CreativeOutput:
await self.ensure_workspace_permission( await self.ensure_workspace_permission(
workspace_id, workspace_id, user_id, domain.PermissionKey.PROJECTS_WRITE, for_project_id=project_id
user_id,
domain.PermissionKey.PROJECTS_WRITE,
for_project_id=project_id,
) )
project = await self.database.get_project(workspace_id, 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) log.warning('User %s attempted to create creative for unavailable project %s', user_id, project_id)
raise domain.ProjectNotFound(project_id) raise domain.ProjectNotFound(project_id)
creative_text = domain.replace_invite_link_with_tag(input.text)
creative = domain.Creative( creative = domain.Creative(
name=input.name, name=input.name,
text=input.text, text=creative_text,
status=domain.CreativeStatus.ACTIVE, status=domain.CreativeStatus.ACTIVE,
project_id=project.id, project_id=project.id,
workspace_id=workspace_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 src import domain
from .text_formatting import prepare_creative_text_html
if TYPE_CHECKING: if TYPE_CHECKING:
from .. import Usecase from .. import Usecase
@@ -32,8 +30,8 @@ async def tg_create_creative_4(
await self.telegram_bot.send_message( await self.telegram_bot.send_message(
'✅ Название сохранено!\n\n' '✅ Название сохранено!\n\n'
'💬 Теперь отправьте или перешлите текст креатива.\n' '💬 Теперь отправьте или перешлите текст креатива.\n'
'Используйте {link} для обозначения места вставки ссылки на ваш канал.\n' 'Пригласительная ссылка на ваш канал будет автоматически '
'Вы также можете указать текст ссылки в формате [текст]{link}.', 'заменена на уникальную ссылку для отслеживания.',
chat_id, chat_id,
) )
@@ -85,7 +83,7 @@ async def tg_create_creative_4(
return return
message_html = text_html or text 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( creative = domain.Creative(
name=creative_name, name=creative_name,

View File

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

View File

@@ -3,8 +3,6 @@ from typing import TYPE_CHECKING
from src import domain, dto from src import domain, dto
from .helpers import generate_post_url
if TYPE_CHECKING: if TYPE_CHECKING:
from .. import Usecase 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) context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, placement.project_id)
# Compute post-related fields ad_post_url = placement.post.url if placement.post else None
ad_post_url = None
if placement.post:
ad_post_url = generate_post_url(placement.post.channel.username, placement.post.message_id)
subscriptions_count = await self.database.count_subscriptions_by_placement(placement.id) 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 src import domain, dto
from .helpers import generate_post_url
if TYPE_CHECKING: if TYPE_CHECKING:
from .. import Usecase from .. import Usecase
@@ -33,10 +31,7 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> list
placement_outputs = [] placement_outputs = []
for placement in placements: for placement in placements:
ad_post_url = None ad_post_url = placement.post.url if placement.post else None
if placement.post:
ad_post_url = generate_post_url(placement.post.channel.username, placement.post.message_id)
placement_outputs.append( placement_outputs.append(
dto.PlacementOutput( 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 src import domain, dto
from .helpers import generate_post_url
if TYPE_CHECKING: if TYPE_CHECKING:
from .. import Usecase from .. import Usecase
@@ -39,10 +37,7 @@ async def update_placement(
await self.database.update_placement(placement) await self.database.update_placement(placement)
ad_post_url = None ad_post_url = placement.post.url if placement.post else None
if placement.post:
ad_post_url = generate_post_url(placement.post.channel.username, placement.post.message_id)
subscriptions_count = await self.database.count_subscriptions_by_placement(placement.id) 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 aiogram.types import InlineKeyboardButton
from src import domain, dto from src import domain, dto
from src.usecase.workspace.mappers import workspace_invite_to_dto
if TYPE_CHECKING: if TYPE_CHECKING:
from .. import Usecase from .. import Usecase
@@ -73,4 +72,4 @@ async def create_workspace_invite(
buttons=[[accept_button]], 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 typing import TYPE_CHECKING
from src import domain, dto from src import domain, dto
from src.usecase.workspace.mappers import workspace_invite_to_dto
if TYPE_CHECKING: if TYPE_CHECKING:
from .. import Usecase from .. import Usecase
@@ -17,4 +16,4 @@ async def get_workspace_invites(
invites = await self.database.get_workspace_invites(workspace_id) 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 typing import TYPE_CHECKING
from src import domain, dto from src import domain, dto
from src.usecase.workspace.mappers import workspace_member_to_dto
if TYPE_CHECKING: if TYPE_CHECKING:
from .. import Usecase from .. import Usecase
@@ -17,4 +16,4 @@ async def get_workspace_members(
members = await self.database.get_workspace_members(workspace_id) 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 typing import TYPE_CHECKING
from src import domain, dto from src import domain, dto
from src.usecase.workspace.mappers import workspace_member_to_dto
if TYPE_CHECKING: if TYPE_CHECKING:
from .. import Usecase from .. import Usecase
@@ -43,4 +42,4 @@ async def update_workspace_member_permissions(
if not updated_member: if not updated_member:
raise domain.WorkspaceAccessDenied(workspace_id) raise domain.WorkspaceAccessDenied(workspace_id)
return workspace_member_to_dto(updated_member) return dto.WorkspaceMemberOutput.from_domain(updated_member)