diff --git a/src/domain/__init__.py b/src/domain/__init__.py index 66c6728..6522424 100644 --- a/src/domain/__init__.py +++ b/src/domain/__init__.py @@ -20,6 +20,7 @@ __all__ = ( 'PurchasePlanStatus', 'PurchasePlanChannelStatus', 'Creative', + 'replace_invite_link_with_tag', 'Placement', 'Post', 'Subscription', @@ -52,17 +53,21 @@ __all__ = ( 'TelegramChannelNotFound', 'CreativeNotFound', 'CreativeInUse', + 'CreativeInviteLinkNotFound', + 'CreativeMultipleInviteLinks', 'PlacementNotFound', 'UserByUsernameNotFound', ) from .channel import Channel -from .creative import Creative, CreativeStatus +from .creative import Creative, CreativeStatus, replace_invite_link_with_tag from .error import ( ChannelAlreadyExists, ChannelNoAdminRights, ChannelNotFound, CreativeInUse, + CreativeInviteLinkNotFound, + CreativeMultipleInviteLinks, CreativeNotFound, LoginTokenAlreadyUsed, LoginTokenExpired, diff --git a/src/domain/creative.py b/src/domain/creative.py index eed0e6c..b073989 100644 --- a/src/domain/creative.py +++ b/src/domain/creative.py @@ -1,10 +1,12 @@ import enum +import re from typing import TYPE_CHECKING from uuid import UUID from tortoise import fields from .base import TimestampedModel +from .error import CreativeInviteLinkNotFound, CreativeMultipleInviteLinks if TYPE_CHECKING: from .project import Project @@ -34,3 +36,24 @@ class Creative(TimestampedModel): class Meta: table = 'creative' + + +_INVITE_LINK_HTML = re.compile(r'([^<]*)', 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'\1', text) + return _INVITE_LINK_PLAIN.sub('', text) diff --git a/src/domain/error.py b/src/domain/error.py index 051f3ab..0ab2fef 100644 --- a/src/domain/error.py +++ b/src/domain/error.py @@ -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') +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: return HTTPException( status.HTTP_400_BAD_REQUEST, diff --git a/src/domain/post.py b/src/domain/post.py index 95b9098..6ff74dd 100644 --- a/src/domain/post.py +++ b/src/domain/post.py @@ -24,3 +24,9 @@ class Post(TimestampedModel): class Meta: table = 'post' 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}' diff --git a/src/domain/workspace.py b/src/domain/workspace.py index 82219bc..0ddb9d2 100644 --- a/src/domain/workspace.py +++ b/src/domain/workspace.py @@ -101,10 +101,14 @@ class PermissionScopeType(enum.StrEnum): class WorkspaceUserPermission(TimestampedModel): + permission = fields.CharEnumField(PermissionKey) + workspace_user: fields.ForeignKeyRelation[WorkspaceUser] = fields.ForeignKeyField( '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: table = 'workspace_user_permission' @@ -120,6 +124,9 @@ class WorkspaceUserPermissionScope(TimestampedModel): 'models.WorkspaceUser', related_name='permission_scopes', on_delete=fields.CASCADE, index=True ) + if TYPE_CHECKING: + workspace_user_id: uuid.UUID + class Meta: table = 'workspace_user_permission_scope' unique_together = (('workspace_user_id', 'permission', 'scope_type', 'scope_id'),) diff --git a/src/dto/workspace.py b/src/dto/workspace.py index 50d93bd..d82682c 100644 --- a/src/dto/workspace.py +++ b/src/dto/workspace.py @@ -47,6 +47,35 @@ class WorkspaceMemberOutput(pydantic.BaseModel): user: WorkspaceMemberUserOutput 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): members: list[WorkspaceMemberOutput] @@ -119,6 +148,26 @@ class WorkspaceInviteOutput(pydantic.BaseModel): user: 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): invites: list[WorkspaceInviteOutput] diff --git a/src/usecase/creative/create_creative.py b/src/usecase/creative/create_creative.py index 8214421..3131c0e 100644 --- a/src/usecase/creative/create_creative.py +++ b/src/usecase/creative/create_creative.py @@ -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, diff --git a/src/usecase/creative/text_formatting.py b/src/usecase/creative/text_formatting.py deleted file mode 100644 index 5bf76cd..0000000 --- a/src/usecase/creative/text_formatting.py +++ /dev/null @@ -1,22 +0,0 @@ -import re - -_LINK_WITH_LABEL_PATTERN: re.Pattern[str] = re.compile(r'\[(?P