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