90% сеньоров плачат на таких коммитах
This commit is contained in:
@@ -177,22 +177,17 @@ class Postgres(DatabaseBase):
|
||||
|
||||
async def get_channel(
|
||||
self,
|
||||
workspace_id: uuid.UUID,
|
||||
channel_id: uuid.UUID | None = None,
|
||||
telegram_id: int | None = None,
|
||||
username: str | None = None,
|
||||
) -> domain.Channel | None:
|
||||
query = domain.Channel.filter(workspace_id=workspace_id)
|
||||
if channel_id:
|
||||
query = query.filter(id=channel_id)
|
||||
return await domain.Channel.get_or_none(id=channel_id)
|
||||
if telegram_id:
|
||||
query = query.filter(telegram_id=telegram_id)
|
||||
return await domain.Channel.get_or_none(telegram_id=telegram_id)
|
||||
if username:
|
||||
query = query.filter(username=username)
|
||||
return await query.first()
|
||||
|
||||
async def get_workspace_channels(self, workspace_id: uuid.UUID) -> list[domain.Channel]:
|
||||
return await domain.Channel.filter(workspace_id=workspace_id).all()
|
||||
return await domain.Channel.filter(username__iexact=username).first()
|
||||
return None
|
||||
|
||||
async def create_channel(self, channel: domain.Channel) -> None:
|
||||
await channel.save()
|
||||
@@ -345,7 +340,7 @@ class Postgres(DatabaseBase):
|
||||
|
||||
async def get_placement(self, workspace_id: uuid.UUID, placement_id: uuid.UUID) -> domain.Placement | None:
|
||||
return await domain.Placement.get_or_none(id=placement_id, workspace_id=workspace_id).prefetch_related(
|
||||
'project', 'project__channel', 'placement_channel', 'creative'
|
||||
'project', 'project__channel', 'placement_channel', 'creative', 'post', 'post__channel'
|
||||
)
|
||||
|
||||
async def create_placement(self, placement: domain.Placement) -> None:
|
||||
@@ -380,7 +375,9 @@ class Postgres(DatabaseBase):
|
||||
query = query.filter(status=domain.PlacementStatus.ACTIVE)
|
||||
|
||||
return (
|
||||
await query.prefetch_related('project', 'project__channel', 'placement_channel', 'creative')
|
||||
await query.prefetch_related(
|
||||
'project', 'project__channel', 'placement_channel', 'creative', 'post', 'post__channel'
|
||||
)
|
||||
.order_by('-placement_date')
|
||||
.all()
|
||||
)
|
||||
@@ -430,17 +427,28 @@ class Postgres(DatabaseBase):
|
||||
.first()
|
||||
)
|
||||
|
||||
async def create_placement_views_history(self, history: domain.PlacementViewsHistory) -> None:
|
||||
# Post methods
|
||||
async def create_post(self, post: domain.Post) -> None:
|
||||
await post.save()
|
||||
|
||||
async def get_post(self, post_id: uuid.UUID) -> domain.Post | None:
|
||||
return await domain.Post.get_or_none(id=post_id).prefetch_related('channel')
|
||||
|
||||
async def get_post_by_channel_and_message(self, channel_id: uuid.UUID, message_id: int) -> domain.Post | None:
|
||||
return await domain.Post.get_or_none(channel_id=channel_id, message_id=message_id).prefetch_related('channel')
|
||||
|
||||
# Post views history
|
||||
async def create_post_views_history(self, history: domain.PostViewsHistory) -> None:
|
||||
await history.save()
|
||||
|
||||
async def get_views_history(
|
||||
self,
|
||||
placement_id: uuid.UUID,
|
||||
post_id: uuid.UUID,
|
||||
*,
|
||||
from_date: datetime.datetime | None = None,
|
||||
to_date: datetime.datetime | None = None,
|
||||
) -> list[domain.PlacementViewsHistory]:
|
||||
query = domain.PlacementViewsHistory.filter(placement_id=placement_id)
|
||||
) -> list[domain.PostViewsHistory]:
|
||||
query = domain.PostViewsHistory.filter(post_id=post_id)
|
||||
|
||||
if from_date:
|
||||
query = query.filter(fetched_at__gte=from_date)
|
||||
@@ -449,6 +457,26 @@ class Postgres(DatabaseBase):
|
||||
|
||||
return await query.order_by('fetched_at').all()
|
||||
|
||||
async def get_latest_views_data(self, post_id: uuid.UUID) -> tuple[int, datetime.datetime] | None:
|
||||
latest = await domain.PostViewsHistory.filter(post_id=post_id).order_by('-fetched_at').first()
|
||||
if latest:
|
||||
return (latest.views_count, latest.fetched_at)
|
||||
return None
|
||||
|
||||
async def get_latest_views_data_batch(
|
||||
self, post_ids: list[uuid.UUID]
|
||||
) -> dict[uuid.UUID, tuple[int, datetime.datetime]]:
|
||||
if not post_ids:
|
||||
return {}
|
||||
|
||||
results: dict[uuid.UUID, tuple[int, datetime.datetime]] = {}
|
||||
for post_id in post_ids:
|
||||
latest = await domain.PostViewsHistory.filter(post_id=post_id).order_by('-fetched_at').first()
|
||||
if latest:
|
||||
results[post_id] = (latest.views_count, latest.fetched_at)
|
||||
|
||||
return results
|
||||
|
||||
async def get_telegram_state(self, telegram_id: int) -> domain.TelegramState | None:
|
||||
return await domain.TelegramState.get_or_none(telegram_id=telegram_id)
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ __all__ = (
|
||||
'PermissionKey',
|
||||
'PermissionScopeType',
|
||||
'Channel',
|
||||
'ChannelVerificationStatus',
|
||||
'Project',
|
||||
'ProjectStatus',
|
||||
'PurchasePlan',
|
||||
@@ -22,8 +21,9 @@ __all__ = (
|
||||
'PurchasePlanChannelStatus',
|
||||
'Creative',
|
||||
'Placement',
|
||||
'Post',
|
||||
'Subscription',
|
||||
'PlacementViewsHistory',
|
||||
'PostViewsHistory',
|
||||
'TelegramState',
|
||||
'TelegramStateEnum',
|
||||
'ChannelNotFound',
|
||||
@@ -55,7 +55,7 @@ __all__ = (
|
||||
'UserByUsernameNotFound',
|
||||
)
|
||||
|
||||
from .channel import Channel, ChannelVerificationStatus
|
||||
from .channel import Channel
|
||||
from .creative import Creative, CreativeStatus
|
||||
from .error import (
|
||||
ChannelAlreadyExists,
|
||||
@@ -80,7 +80,8 @@ from .error import (
|
||||
)
|
||||
from .login_token import LoginToken
|
||||
from .placement import InviteLinkType, Placement, PlacementStatus, PostViewsAvailability
|
||||
from .placement_views_history import PlacementViewsHistory
|
||||
from .post import Post
|
||||
from .post_views_history import PostViewsHistory
|
||||
from .project import Project, ProjectStatus
|
||||
from .purchase_plan import (
|
||||
PurchasePlan,
|
||||
|
||||
@@ -3,6 +3,7 @@ from tortoise.models import Model
|
||||
|
||||
|
||||
class TimestampedModel(Model):
|
||||
id = fields.UUIDField(pk=True)
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
updated_at = fields.DatetimeField(auto_now=True)
|
||||
deleted_at = fields.DatetimeField(null=True)
|
||||
|
||||
@@ -1,34 +1,15 @@
|
||||
import enum
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from tortoise import fields
|
||||
|
||||
from .base import TimestampedModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .workspace import Workspace
|
||||
|
||||
|
||||
class ChannelVerificationStatus(str, enum.Enum):
|
||||
UNVERIFIED = 'unverified'
|
||||
VERIFIED = 'verified'
|
||||
FAILED = 'failed'
|
||||
|
||||
|
||||
class Channel(TimestampedModel):
|
||||
id = fields.UUIDField(pk=True)
|
||||
telegram_id = fields.BigIntField(null=True, unique=True, index=True)
|
||||
username = fields.CharField(max_length=255, null=True, index=True)
|
||||
title = fields.CharField(max_length=255, null=True)
|
||||
verification_status = fields.CharEnumField(ChannelVerificationStatus, default=ChannelVerificationStatus.UNVERIFIED)
|
||||
title = fields.CharField(null=True, max_length=255)
|
||||
username = fields.CharField(null=True, max_length=255, index=True)
|
||||
|
||||
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
|
||||
'models.Workspace', related_name='channels', on_delete=fields.CASCADE, index=True
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
workspace_id: uuid.UUID
|
||||
access_hash = fields.BigIntField(null=True)
|
||||
pts = fields.IntField(null=True)
|
||||
|
||||
class Meta:
|
||||
table = 'channel'
|
||||
|
||||
@@ -17,7 +17,6 @@ class CreativeStatus(str, enum.Enum):
|
||||
|
||||
|
||||
class Creative(TimestampedModel):
|
||||
id = fields.UUIDField(pk=True)
|
||||
name = fields.CharField(max_length=255)
|
||||
text = fields.TextField()
|
||||
status = fields.CharEnumField(CreativeStatus, default=CreativeStatus.ACTIVE)
|
||||
|
||||
@@ -10,7 +10,6 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class LoginToken(TimestampedModel):
|
||||
id = fields.UUIDField(pk=True)
|
||||
token = fields.CharField(max_length=255, unique=True, index=True)
|
||||
user: fields.ForeignKeyRelation['User'] = fields.ForeignKeyField(
|
||||
'models.User', related_name='login_tokens', on_delete=fields.CASCADE
|
||||
|
||||
@@ -9,6 +9,7 @@ from .base import TimestampedModel
|
||||
if TYPE_CHECKING:
|
||||
from .channel import Channel
|
||||
from .creative import Creative
|
||||
from .post import Post
|
||||
from .project import Project
|
||||
from .workspace import Workspace
|
||||
|
||||
@@ -33,22 +34,14 @@ class PostViewsAvailability(str, enum.Enum):
|
||||
|
||||
|
||||
class Placement(TimestampedModel):
|
||||
id = fields.UUIDField(pk=True)
|
||||
placement_date = fields.DatetimeField()
|
||||
cost = fields.FloatField(null=True)
|
||||
comment = fields.TextField(null=True)
|
||||
ad_post_url = fields.CharField(max_length=512, null=True)
|
||||
invite_link_type = fields.CharEnumField(InviteLinkType)
|
||||
invite_link = fields.CharField(max_length=512)
|
||||
external_channel_message_id = fields.IntField(null=True)
|
||||
|
||||
status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.ACTIVE)
|
||||
|
||||
subscriptions_count = fields.IntField(default=0)
|
||||
views_count = fields.IntField(null=True)
|
||||
|
||||
views_availability = fields.CharEnumField(PostViewsAvailability, default=PostViewsAvailability.UNKNOWN)
|
||||
last_views_fetch_at = fields.DatetimeField(null=True)
|
||||
|
||||
project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
|
||||
'models.Project', related_name='placements', on_delete=fields.CASCADE, index=True
|
||||
@@ -62,12 +55,16 @@ class Placement(TimestampedModel):
|
||||
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
|
||||
'models.Workspace', related_name='placements', on_delete=fields.CASCADE, index=True
|
||||
)
|
||||
post: fields.ForeignKeyRelation['Post'] | None = fields.ForeignKeyField(
|
||||
'models.Post', related_name='placements', on_delete=fields.SET_NULL, null=True, index=True
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
project_id: UUID
|
||||
placement_channel_id: UUID
|
||||
creative_id: UUID
|
||||
workspace_id: UUID
|
||||
post_id: UUID | None
|
||||
|
||||
class Meta:
|
||||
table = 'placement'
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
from tortoise import fields
|
||||
|
||||
from .base import TimestampedModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .placement import Placement
|
||||
|
||||
|
||||
class PlacementViewsHistory(TimestampedModel):
|
||||
id = fields.UUIDField(pk=True)
|
||||
|
||||
views_count = fields.IntField() # Количество просмотров на момент снимка
|
||||
fetched_at = fields.DatetimeField(index=True)
|
||||
|
||||
placement: fields.ForeignKeyRelation['Placement'] = fields.ForeignKeyField(
|
||||
'models.Placement', related_name='views_histories', on_delete=fields.CASCADE, index=True
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
placement_id: UUID
|
||||
|
||||
class Meta:
|
||||
table = 'placement_views_history'
|
||||
26
src/domain/post.py
Normal file
26
src/domain/post.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from tortoise import fields
|
||||
|
||||
from .base import TimestampedModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .channel import Channel
|
||||
|
||||
|
||||
class Post(TimestampedModel):
|
||||
message_id = fields.IntField()
|
||||
text = fields.TextField()
|
||||
deleted_from_channel_at = fields.DatetimeField(null=True)
|
||||
|
||||
channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField(
|
||||
'models.Channel', related_name='posts', on_delete=fields.CASCADE, index=True
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
channel_id: uuid.UUID
|
||||
|
||||
class Meta:
|
||||
table = 'post'
|
||||
unique_together = (('channel_id', 'message_id'),)
|
||||
24
src/domain/post_views_history.py
Normal file
24
src/domain/post_views_history.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
from tortoise import fields
|
||||
|
||||
from .base import TimestampedModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .post import Post
|
||||
|
||||
|
||||
class PostViewsHistory(TimestampedModel):
|
||||
views_count = fields.IntField() # Количество просмотров на момент снимка
|
||||
fetched_at = fields.DatetimeField(index=True)
|
||||
|
||||
post: fields.ForeignKeyRelation['Post'] = fields.ForeignKeyField(
|
||||
'models.Post', related_name='views_histories', on_delete=fields.CASCADE, index=True
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
post_id: UUID
|
||||
|
||||
class Meta:
|
||||
table = 'post_views_history'
|
||||
@@ -18,7 +18,6 @@ class ProjectStatus(str, enum.Enum):
|
||||
|
||||
|
||||
class Project(TimestampedModel):
|
||||
id = fields.UUIDField(pk=True)
|
||||
status = fields.CharEnumField(ProjectStatus, default=ProjectStatus.ACTIVE)
|
||||
|
||||
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
|
||||
|
||||
@@ -26,7 +26,6 @@ class PurchasePlanChannelStatus(str, enum.Enum):
|
||||
|
||||
|
||||
class PurchasePlan(TimestampedModel):
|
||||
id = fields.UUIDField(pk=True)
|
||||
status = fields.CharEnumField(PurchasePlanStatus, default=PurchasePlanStatus.ACTIVE)
|
||||
|
||||
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
|
||||
@@ -42,7 +41,6 @@ class PurchasePlan(TimestampedModel):
|
||||
|
||||
|
||||
class PurchasePlanChannel(TimestampedModel):
|
||||
id = fields.UUIDField(pk=True)
|
||||
status = fields.CharEnumField(PurchasePlanChannelStatus, default=PurchasePlanChannelStatus.PLANNED)
|
||||
planned_cost = fields.FloatField(null=True)
|
||||
comment = fields.TextField(null=True)
|
||||
|
||||
@@ -17,7 +17,6 @@ class SubscriptionStatus(str, enum.Enum):
|
||||
|
||||
|
||||
class Subscription(TimestampedModel):
|
||||
id = fields.UUIDField(pk=True)
|
||||
invite_link = fields.CharField(max_length=512, index=True)
|
||||
status = fields.CharEnumField(SubscriptionStatus, default=SubscriptionStatus.ACTIVE)
|
||||
unsubscribed_at = fields.DatetimeField(null=True)
|
||||
|
||||
@@ -14,7 +14,6 @@ class TelegramStateEnum(str, enum.Enum):
|
||||
|
||||
|
||||
class TelegramState(TimestampedModel):
|
||||
id = fields.UUIDField(pk=True)
|
||||
telegram_id = fields.BigIntField(unique=True, index=True)
|
||||
state = fields.CharEnumField(TelegramStateEnum)
|
||||
context = fields.JSONField(default=dict)
|
||||
|
||||
@@ -4,7 +4,6 @@ from .base import TimestampedModel
|
||||
|
||||
|
||||
class User(TimestampedModel):
|
||||
id = fields.UUIDField(pk=True)
|
||||
telegram_id = fields.BigIntField(unique=True, index=True)
|
||||
username = fields.CharField(max_length=255, null=True)
|
||||
first_name = fields.CharField(max_length=255, null=True)
|
||||
|
||||
@@ -27,7 +27,6 @@ class WorkspaceUserStatus(str, enum.Enum):
|
||||
|
||||
|
||||
class WorkspaceUser(TimestampedModel):
|
||||
id = fields.UUIDField(pk=True)
|
||||
status = fields.CharEnumField(WorkspaceUserStatus, default=WorkspaceUserStatus.ACTIVE)
|
||||
|
||||
workspace: fields.ForeignKeyRelation[Workspace] = fields.ForeignKeyField(
|
||||
@@ -53,7 +52,6 @@ class WorkspaceInviteStatus(str, enum.Enum):
|
||||
|
||||
|
||||
class WorkspaceInvite(TimestampedModel):
|
||||
id = fields.UUIDField(pk=True)
|
||||
status = fields.CharEnumField(WorkspaceInviteStatus, default=WorkspaceInviteStatus.PENDING)
|
||||
|
||||
workspace: fields.ForeignKeyRelation[Workspace] = fields.ForeignKeyField(
|
||||
@@ -114,7 +112,6 @@ class WorkspaceUserPermission(TimestampedModel):
|
||||
|
||||
|
||||
class WorkspaceUserPermissionScope(TimestampedModel):
|
||||
id = fields.UUIDField(pk=True)
|
||||
permission = fields.CharEnumField(PermissionKey)
|
||||
scope_type = fields.CharEnumField(PermissionScopeType)
|
||||
scope_id = fields.UUIDField()
|
||||
|
||||
@@ -31,7 +31,7 @@ __all__ = (
|
||||
'CreatePlacementInput',
|
||||
'UpdatePlacementInput',
|
||||
'DeletePlacementInput',
|
||||
'PlacementViewsHistoryOutput',
|
||||
'PostViewsHistoryOutput',
|
||||
'GetViewsHistoryInput',
|
||||
'GetViewsHistoryOutput',
|
||||
'UpdateViewsManuallyInput',
|
||||
@@ -123,7 +123,7 @@ from .validate_login_token import ValidateLoginTokenInput, ValidateLoginTokenOut
|
||||
from .views import (
|
||||
GetViewsHistoryInput,
|
||||
GetViewsHistoryOutput,
|
||||
PlacementViewsHistoryOutput,
|
||||
PostViewsHistoryOutput,
|
||||
UpdateViewsManuallyInput,
|
||||
)
|
||||
from .workspace import (
|
||||
|
||||
@@ -2,15 +2,12 @@ import uuid
|
||||
|
||||
import pydantic
|
||||
|
||||
from src.domain.channel import ChannelVerificationStatus
|
||||
|
||||
|
||||
class ChannelOutput(pydantic.BaseModel):
|
||||
id: uuid.UUID
|
||||
telegram_id: int | None
|
||||
title: str | None
|
||||
username: str | None
|
||||
verification_status: ChannelVerificationStatus
|
||||
|
||||
|
||||
class GetChannelsInput(pydantic.BaseModel):
|
||||
|
||||
@@ -4,11 +4,11 @@ import uuid
|
||||
import pydantic
|
||||
|
||||
|
||||
class PlacementViewsHistoryOutput(pydantic.BaseModel):
|
||||
"""История просмотров placement."""
|
||||
class PostViewsHistoryOutput(pydantic.BaseModel):
|
||||
"""История просмотров поста."""
|
||||
|
||||
id: uuid.UUID
|
||||
placement_id: uuid.UUID
|
||||
post_id: uuid.UUID
|
||||
views_count: int
|
||||
fetched_at: datetime.datetime
|
||||
created_at: datetime.datetime
|
||||
@@ -27,7 +27,7 @@ class GetViewsHistoryInput(pydantic.BaseModel):
|
||||
class GetViewsHistoryOutput(pydantic.BaseModel):
|
||||
"""История просмотров."""
|
||||
|
||||
histories: list[PlacementViewsHistoryOutput]
|
||||
histories: list[PostViewsHistoryOutput]
|
||||
|
||||
|
||||
class UpdateViewsManuallyInput(pydantic.BaseModel):
|
||||
|
||||
@@ -122,14 +122,11 @@ class Database(typing.Protocol):
|
||||
|
||||
async def get_channel(
|
||||
self,
|
||||
workspace_id: UUID,
|
||||
channel_id: UUID | None = None,
|
||||
telegram_id: int | None = None,
|
||||
username: str | None = None,
|
||||
) -> domain.Channel | None: ...
|
||||
|
||||
async def get_workspace_channels(self, workspace_id: UUID) -> list[domain.Channel]: ...
|
||||
|
||||
async def create_channel(self, channel: domain.Channel) -> None: ...
|
||||
|
||||
async def update_channel(self, channel: domain.Channel) -> None: ...
|
||||
@@ -232,15 +229,27 @@ class Database(typing.Protocol):
|
||||
self, subscriber_id: UUID, project_id: UUID
|
||||
) -> domain.Subscription | None: ...
|
||||
|
||||
async def create_placement_views_history(self, history: domain.PlacementViewsHistory) -> None: ...
|
||||
# Post methods
|
||||
async def create_post(self, post: domain.Post) -> None: ...
|
||||
|
||||
async def get_post(self, post_id: UUID) -> domain.Post | None: ...
|
||||
|
||||
async def get_post_by_channel_and_message(self, channel_id: UUID, message_id: int) -> domain.Post | None: ...
|
||||
|
||||
# Post views history
|
||||
async def create_post_views_history(self, history: domain.PostViewsHistory) -> None: ...
|
||||
|
||||
async def get_views_history(
|
||||
self,
|
||||
placement_id: UUID,
|
||||
post_id: UUID,
|
||||
*,
|
||||
from_date: datetime.datetime | None = None,
|
||||
to_date: datetime.datetime | None = None,
|
||||
) -> list[domain.PlacementViewsHistory]: ...
|
||||
) -> list[domain.PostViewsHistory]: ...
|
||||
|
||||
async def get_latest_views_data(self, post_id: UUID) -> tuple[int, datetime.datetime] | None: ...
|
||||
|
||||
async def get_latest_views_data_batch(self, post_ids: list[UUID]) -> dict[UUID, tuple[int, datetime.datetime]]: ...
|
||||
|
||||
async def get_telegram_state(self, telegram_id: int) -> domain.TelegramState | None: ...
|
||||
|
||||
|
||||
@@ -30,7 +30,9 @@ async def get_channel_analytics(self: 'Usecase', input: dto.GetChannelAnalyticsI
|
||||
else:
|
||||
allowed_project_filter = allowed_project_ids
|
||||
if allowed_project_ids is None:
|
||||
channels = await self.database.get_workspace_channels(input.workspace_id)
|
||||
# Получить каналы через Projects
|
||||
projects = await self.database.get_workspace_projects(input.workspace_id)
|
||||
channels = [p.channel for p in projects]
|
||||
else:
|
||||
channels = []
|
||||
|
||||
@@ -57,6 +59,10 @@ async def get_channel_analytics(self: 'Usecase', input: dto.GetChannelAnalyticsI
|
||||
|
||||
channel_stats: dict[UUID, ChannelStats] = {ch.id: ChannelStats() for ch in channels}
|
||||
|
||||
# Batch fetch views data for all posts
|
||||
post_ids = [p.post.id for p in placements if p.post]
|
||||
views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {}
|
||||
|
||||
for p in placements:
|
||||
stats = channel_stats.get(p.placement_channel_id)
|
||||
if stats is None:
|
||||
@@ -64,7 +70,12 @@ async def get_channel_analytics(self: 'Usecase', input: dto.GetChannelAnalyticsI
|
||||
|
||||
stats.total_cost += p.cost if p.cost is not None else 0
|
||||
stats.total_subscriptions += p.subscriptions_count
|
||||
stats.total_views += p.views_count if p.views_count is not None else 0
|
||||
|
||||
# Get views from batch data
|
||||
if p.post and p.post.id in views_map:
|
||||
views_count = views_map[p.post.id][0]
|
||||
stats.total_views += views_count
|
||||
|
||||
stats.placements_count += 1
|
||||
|
||||
result = []
|
||||
|
||||
@@ -49,6 +49,10 @@ async def get_creatives_analytics(
|
||||
|
||||
creative_stats: dict[UUID, CreativeStats] = {cr.id: CreativeStats() for cr in creatives}
|
||||
|
||||
# Batch fetch views data for all posts
|
||||
post_ids = [p.post.id for p in placements if p.post]
|
||||
views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {}
|
||||
|
||||
for p in placements:
|
||||
stats = creative_stats.get(p.creative_id)
|
||||
if stats is None:
|
||||
@@ -56,7 +60,12 @@ async def get_creatives_analytics(
|
||||
|
||||
stats.total_cost += p.cost if p.cost is not None else 0
|
||||
stats.total_subscriptions += p.subscriptions_count
|
||||
stats.total_views += p.views_count if p.views_count is not None else 0
|
||||
|
||||
# Get views from batch data
|
||||
if p.post and p.post.id in views_map:
|
||||
views_count = views_map[p.post.id][0]
|
||||
stats.total_views += views_count
|
||||
|
||||
stats.placements_count += 1
|
||||
|
||||
result = []
|
||||
|
||||
@@ -44,6 +44,10 @@ async def get_placements_analytics(
|
||||
allowed_project_ids=allowed_project_ids,
|
||||
)
|
||||
|
||||
# Batch fetch views data for all posts
|
||||
post_ids = [p.post.id for p in placements if p.post]
|
||||
views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {}
|
||||
|
||||
result = [
|
||||
dto.PlacementAnalyticsOutput(
|
||||
id=p.id,
|
||||
@@ -56,9 +60,9 @@ async def get_placements_analytics(
|
||||
placement_date=p.placement_date,
|
||||
cost=p.cost,
|
||||
subscriptions_count=p.subscriptions_count,
|
||||
views_count=p.views_count,
|
||||
views_count=views_map[p.post.id][0] if p.post and p.post.id in views_map else None,
|
||||
cpf=_calculate_cpf(p.cost, p.subscriptions_count),
|
||||
cpm=_calculate_cpm(p.cost, p.views_count),
|
||||
cpm=_calculate_cpm(p.cost, views_map[p.post.id][0] if p.post and p.post.id in views_map else None),
|
||||
)
|
||||
for p in placements
|
||||
]
|
||||
|
||||
@@ -70,6 +70,10 @@ async def get_spending_analytics(
|
||||
subscriptions: int = 0
|
||||
views: int = 0
|
||||
|
||||
# Batch fetch views data for all posts
|
||||
post_ids = [p.post.id for p in filtered if p.post]
|
||||
views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {}
|
||||
|
||||
# Группировка по периодам
|
||||
period_data: dict[str, PeriodData] = defaultdict(PeriodData)
|
||||
|
||||
@@ -84,9 +88,11 @@ async def get_spending_analytics(
|
||||
total_subs += p.subscriptions_count
|
||||
pd.subscriptions += p.subscriptions_count
|
||||
|
||||
if p.views_count is not None:
|
||||
total_views += p.views_count
|
||||
pd.views += p.views_count
|
||||
# Get views from batch data
|
||||
if p.post and p.post.id in views_map:
|
||||
views_count = views_map[p.post.id][0]
|
||||
total_views += views_count
|
||||
pd.views += views_count
|
||||
|
||||
avg_cpf = total_cost / total_subs if total_subs > 0 and total_cost > 0 else None
|
||||
avg_cpm = (total_cost / total_views * 1000) if total_views > 0 and total_cost > 0 else None
|
||||
|
||||
@@ -21,7 +21,6 @@ async def get_channels(self: 'Usecase', input: dto.GetChannelsInput) -> dto.GetC
|
||||
telegram_id=channel.telegram_id,
|
||||
title=channel.title,
|
||||
username=channel.username,
|
||||
verification_status=channel.verification_status,
|
||||
)
|
||||
for channel in channels
|
||||
]
|
||||
|
||||
@@ -17,9 +17,7 @@ async def update_creative(
|
||||
user_id: uuid.UUID,
|
||||
workspace_id: uuid.UUID,
|
||||
) -> dto.CreativeOutput:
|
||||
context = await self.ensure_workspace_permission(
|
||||
workspace_id, user_id, domain.PermissionKey.PROJECTS_WRITE
|
||||
)
|
||||
context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PROJECTS_WRITE)
|
||||
|
||||
creative = await self.database.get_creative(workspace_id, creative_id)
|
||||
if not creative:
|
||||
|
||||
@@ -24,7 +24,7 @@ async def create_placement(
|
||||
if not project:
|
||||
raise domain.ProjectNotFound(input.project_id)
|
||||
|
||||
placement_channel = await self.database.get_channel(workspace_id, channel_id=input.placement_channel_id)
|
||||
placement_channel = await self.database.get_channel(channel_id=input.placement_channel_id)
|
||||
if not placement_channel:
|
||||
raise domain.ChannelNotFound(input.placement_channel_id)
|
||||
|
||||
@@ -39,6 +39,8 @@ async def create_placement(
|
||||
if project.channel.telegram_id is None:
|
||||
raise domain.ChannelNotFound(project.channel.id)
|
||||
|
||||
print(f'Телега id: {project.channel.telegram_id}')
|
||||
|
||||
invite_link = await self.telegram_bot.create_chat_invite_link(project.channel.telegram_id, requires_approval)
|
||||
|
||||
placement = domain.Placement(
|
||||
@@ -71,13 +73,13 @@ async def create_placement(
|
||||
placement_date=placement.placement_date,
|
||||
cost=placement.cost,
|
||||
comment=placement.comment,
|
||||
ad_post_url=placement.ad_post_url,
|
||||
ad_post_url=None,
|
||||
invite_link_type=placement.invite_link_type,
|
||||
invite_link=placement.invite_link,
|
||||
status=placement.status,
|
||||
subscriptions_count=placement.subscriptions_count,
|
||||
views_count=placement.views_count,
|
||||
views_availability=placement.views_availability,
|
||||
last_views_fetch_at=placement.last_views_fetch_at,
|
||||
views_count=None,
|
||||
views_availability=domain.PostViewsAvailability.UNKNOWN,
|
||||
last_views_fetch_at=None,
|
||||
created_at=placement.created_at,
|
||||
)
|
||||
|
||||
@@ -3,6 +3,8 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain, dto
|
||||
|
||||
from .helpers import generate_post_url
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
@@ -21,6 +23,21 @@ 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
|
||||
views_count = None
|
||||
views_availability = domain.PostViewsAvailability.UNKNOWN
|
||||
last_views_fetch_at = None
|
||||
|
||||
if placement.post:
|
||||
ad_post_url = generate_post_url(placement.post.channel.username, placement.post.message_id)
|
||||
|
||||
# Get latest views data
|
||||
views_data = await self.database.get_latest_views_data(placement.post.id)
|
||||
if views_data:
|
||||
views_count, last_views_fetch_at = views_data
|
||||
views_availability = domain.PostViewsAvailability.AVAILABLE
|
||||
|
||||
return dto.PlacementOutput(
|
||||
id=placement.id,
|
||||
project_id=placement.project_id,
|
||||
@@ -32,13 +49,13 @@ async def get_placement(self: 'Usecase', input: dto.GetPlacementInput) -> dto.Pl
|
||||
placement_date=placement.placement_date,
|
||||
cost=placement.cost,
|
||||
comment=placement.comment,
|
||||
ad_post_url=placement.ad_post_url,
|
||||
ad_post_url=ad_post_url,
|
||||
invite_link_type=placement.invite_link_type,
|
||||
invite_link=placement.invite_link,
|
||||
status=placement.status,
|
||||
subscriptions_count=placement.subscriptions_count,
|
||||
views_count=placement.views_count,
|
||||
views_availability=placement.views_availability,
|
||||
last_views_fetch_at=placement.last_views_fetch_at,
|
||||
views_count=views_count,
|
||||
views_availability=views_availability,
|
||||
last_views_fetch_at=last_views_fetch_at,
|
||||
created_at=placement.created_at,
|
||||
)
|
||||
|
||||
@@ -2,6 +2,8 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain, dto
|
||||
|
||||
from .helpers import generate_post_url
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
@@ -26,8 +28,27 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.
|
||||
allowed_project_ids=allowed_project_ids,
|
||||
)
|
||||
|
||||
return dto.GetPlacementsOutput(
|
||||
placements=[
|
||||
# Batch fetch views data for all posts
|
||||
post_ids = [p.post.id for p in placements if p.post]
|
||||
views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {}
|
||||
|
||||
# Build output DTOs
|
||||
placement_outputs = []
|
||||
for placement in placements:
|
||||
# Compute post-related fields
|
||||
ad_post_url = None
|
||||
views_count = None
|
||||
views_availability = domain.PostViewsAvailability.UNKNOWN
|
||||
last_views_fetch_at = None
|
||||
|
||||
if placement.post:
|
||||
ad_post_url = generate_post_url(placement.post.channel.username, placement.post.message_id)
|
||||
views_data = views_map.get(placement.post.id)
|
||||
if views_data:
|
||||
views_count, last_views_fetch_at = views_data
|
||||
views_availability = domain.PostViewsAvailability.AVAILABLE
|
||||
|
||||
placement_outputs.append(
|
||||
dto.PlacementOutput(
|
||||
id=placement.id,
|
||||
project_id=placement.project_id,
|
||||
@@ -39,16 +60,16 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.
|
||||
placement_date=placement.placement_date,
|
||||
cost=placement.cost,
|
||||
comment=placement.comment,
|
||||
ad_post_url=placement.ad_post_url,
|
||||
ad_post_url=ad_post_url,
|
||||
invite_link_type=placement.invite_link_type,
|
||||
invite_link=placement.invite_link,
|
||||
status=placement.status,
|
||||
subscriptions_count=placement.subscriptions_count,
|
||||
views_count=placement.views_count,
|
||||
views_availability=placement.views_availability,
|
||||
last_views_fetch_at=placement.last_views_fetch_at,
|
||||
views_count=views_count,
|
||||
views_availability=views_availability,
|
||||
last_views_fetch_at=last_views_fetch_at,
|
||||
created_at=placement.created_at,
|
||||
)
|
||||
for placement in placements
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
return dto.GetPlacementsOutput(placements=placement_outputs)
|
||||
|
||||
7
src/usecase/placement/helpers.py
Normal file
7
src/usecase/placement/helpers.py
Normal file
@@ -0,0 +1,7 @@
|
||||
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}'
|
||||
@@ -4,6 +4,8 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain, dto
|
||||
|
||||
from .helpers import generate_post_url
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
@@ -17,9 +19,7 @@ async def update_placement(
|
||||
user_id: uuid.UUID,
|
||||
workspace_id: uuid.UUID,
|
||||
) -> dto.PlacementOutput:
|
||||
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)
|
||||
|
||||
placement = await self.database.get_placement(workspace_id, placement_id)
|
||||
if not placement:
|
||||
@@ -39,6 +39,21 @@ async def update_placement(
|
||||
|
||||
await self.database.update_placement(placement)
|
||||
|
||||
# Compute post-related fields
|
||||
ad_post_url = None
|
||||
views_count = None
|
||||
views_availability = domain.PostViewsAvailability.UNKNOWN
|
||||
last_views_fetch_at = None
|
||||
|
||||
if placement.post:
|
||||
ad_post_url = generate_post_url(placement.post.channel.username, placement.post.message_id)
|
||||
|
||||
# Get latest views data
|
||||
views_data = await self.database.get_latest_views_data(placement.post.id)
|
||||
if views_data:
|
||||
views_count, last_views_fetch_at = views_data
|
||||
views_availability = domain.PostViewsAvailability.AVAILABLE
|
||||
|
||||
return dto.PlacementOutput(
|
||||
id=placement.id,
|
||||
project_id=placement.project_id,
|
||||
@@ -50,13 +65,13 @@ async def update_placement(
|
||||
placement_date=placement.placement_date,
|
||||
cost=placement.cost,
|
||||
comment=placement.comment,
|
||||
ad_post_url=placement.ad_post_url,
|
||||
ad_post_url=ad_post_url,
|
||||
invite_link_type=placement.invite_link_type,
|
||||
invite_link=placement.invite_link,
|
||||
status=placement.status,
|
||||
subscriptions_count=placement.subscriptions_count,
|
||||
views_count=placement.views_count,
|
||||
views_availability=placement.views_availability,
|
||||
last_views_fetch_at=placement.last_views_fetch_at,
|
||||
views_count=views_count,
|
||||
views_availability=views_availability,
|
||||
last_views_fetch_at=last_views_fetch_at,
|
||||
created_at=placement.created_at,
|
||||
)
|
||||
|
||||
@@ -15,9 +15,7 @@ async def get_workspace_projects(
|
||||
|
||||
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.PROJECTS_READ)
|
||||
|
||||
projects = await self.database.get_workspace_projects(
|
||||
input.workspace_id, allowed_project_ids=allowed_project_ids
|
||||
)
|
||||
projects = await self.database.get_workspace_projects(input.workspace_id, allowed_project_ids=allowed_project_ids)
|
||||
|
||||
return dto.GetWorkspaceProjectsOutput(
|
||||
projects=[
|
||||
|
||||
@@ -70,7 +70,7 @@ async def tg_add_project_1(self: 'Usecase', input: dto.ConnectProjectInput) -> d
|
||||
if len(workspaces) == 1:
|
||||
workspace = workspaces[0]
|
||||
|
||||
channel = await self.database.get_channel(workspace.id, telegram_id=input.telegram_id)
|
||||
channel = await self.database.get_channel(telegram_id=input.telegram_id)
|
||||
if channel:
|
||||
channel.title = input.title
|
||||
channel.username = input.username
|
||||
@@ -80,8 +80,6 @@ async def tg_add_project_1(self: 'Usecase', input: dto.ConnectProjectInput) -> d
|
||||
telegram_id=input.telegram_id,
|
||||
title=input.title,
|
||||
username=input.username,
|
||||
verification_status=domain.ChannelVerificationStatus.VERIFIED,
|
||||
workspace_id=workspace.id,
|
||||
)
|
||||
await self.database.create_channel(channel)
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ async def tg_add_project_2(
|
||||
return
|
||||
|
||||
telegram_channel_id = int(channel_data['telegram_id'])
|
||||
channel = await self.database.get_channel(workspace.id, telegram_id=telegram_channel_id)
|
||||
channel = await self.database.get_channel(telegram_id=telegram_channel_id)
|
||||
if channel:
|
||||
channel.title = str(channel_data['title'])
|
||||
channel.username = channel_data.get('username')
|
||||
@@ -76,7 +76,6 @@ async def tg_add_project_2(
|
||||
telegram_id=telegram_channel_id,
|
||||
title=str(channel_data['title']),
|
||||
username=channel_data.get('username'),
|
||||
workspace_id=workspace.id,
|
||||
)
|
||||
await self.database.create_channel(channel)
|
||||
|
||||
|
||||
@@ -17,9 +17,7 @@ async def attach_channel_to_purchase_plan(
|
||||
user_id: uuid.UUID,
|
||||
input: dto.AttachChannelToPurchasePlanInput,
|
||||
) -> dto.PurchasePlanChannelOutput:
|
||||
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,21 +28,15 @@ async def attach_channel_to_purchase_plan(
|
||||
plan = await self.ensure_active_purchase_plan(project)
|
||||
|
||||
# Поиск канала по username
|
||||
channel = await self.database.get_channel(workspace_id, username=input.username)
|
||||
channel = await self.database.get_channel(username=input.username)
|
||||
|
||||
if not channel:
|
||||
# Создать неподтвержденный канал
|
||||
channel = domain.Channel(
|
||||
workspace_id=workspace_id,
|
||||
username=input.username,
|
||||
telegram_id=None,
|
||||
title=None,
|
||||
verification_status=domain.ChannelVerificationStatus.UNVERIFIED,
|
||||
)
|
||||
# Создать канал с nullable telegram_id/title (заполнятся парсером позже)
|
||||
channel = domain.Channel(username=input.username)
|
||||
await self.database.create_channel(channel)
|
||||
log.info('Created unverified channel with username %s', input.username)
|
||||
log.info('Created channel with username @%s (pending parser update)', input.username)
|
||||
else:
|
||||
log.info('Found existing channel %s for username %s', channel.id, input.username)
|
||||
log.info('Found existing channel %s for username @%s', channel.id, input.username)
|
||||
|
||||
plan_channel = await self.database.add_channel_to_purchase_plan(
|
||||
plan.id,
|
||||
@@ -66,6 +58,5 @@ async def attach_channel_to_purchase_plan(
|
||||
telegram_id=channel.telegram_id,
|
||||
title=channel.title,
|
||||
username=channel.username,
|
||||
verification_status=channel.verification_status,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -39,7 +39,6 @@ async def get_purchase_plan_channels(
|
||||
telegram_id=plan_channel.channel.telegram_id,
|
||||
title=plan_channel.channel.title,
|
||||
username=plan_channel.channel.username,
|
||||
verification_status=plan_channel.channel.verification_status,
|
||||
),
|
||||
)
|
||||
for plan_channel in plan_channels
|
||||
|
||||
@@ -17,9 +17,7 @@ async def remove_purchase_plan_channel(
|
||||
workspace_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> None:
|
||||
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:
|
||||
|
||||
@@ -18,9 +18,7 @@ async def update_purchase_plan_channel(
|
||||
user_id: uuid.UUID,
|
||||
input: dto.UpdatePurchasePlanChannelInput,
|
||||
) -> dto.PurchasePlanChannelOutput:
|
||||
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:
|
||||
@@ -56,7 +54,7 @@ async def update_purchase_plan_channel(
|
||||
|
||||
channel: domain.Channel | None = plan_channel.channel
|
||||
if channel is None:
|
||||
channel = await self.database.get_channel(workspace_id, channel_id=plan_channel.channel_id)
|
||||
channel = await self.database.get_channel(channel_id=plan_channel.channel_id)
|
||||
if channel is None:
|
||||
raise domain.ChannelNotFound(plan_channel.channel_id)
|
||||
|
||||
@@ -70,6 +68,5 @@ async def update_purchase_plan_channel(
|
||||
telegram_id=channel.telegram_id,
|
||||
title=channel.title,
|
||||
username=channel.username,
|
||||
verification_status=channel.verification_status,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -21,17 +21,20 @@ async def get_views_history(self: 'Usecase', input: dto.GetViewsHistoryInput) ->
|
||||
|
||||
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, placement.project_id)
|
||||
|
||||
if not placement.post:
|
||||
return dto.GetViewsHistoryOutput(histories=[])
|
||||
|
||||
histories = await self.database.get_views_history(
|
||||
input.placement_id,
|
||||
placement.post.id,
|
||||
from_date=input.from_date,
|
||||
to_date=input.to_date,
|
||||
)
|
||||
|
||||
return dto.GetViewsHistoryOutput(
|
||||
histories=[
|
||||
dto.PlacementViewsHistoryOutput(
|
||||
dto.PostViewsHistoryOutput(
|
||||
id=history.id,
|
||||
placement_id=history.placement_id,
|
||||
post_id=history.post_id,
|
||||
views_count=history.views_count,
|
||||
fetched_at=history.fetched_at,
|
||||
created_at=history.created_at,
|
||||
|
||||
@@ -17,6 +17,4 @@ async def get_workspace_invites(
|
||||
|
||||
invites = await self.database.get_workspace_invites(workspace_id)
|
||||
|
||||
return dto.GetWorkspaceInvitesOutput(
|
||||
invites=[workspace_invite_to_dto(invite) for invite in invites]
|
||||
)
|
||||
return dto.GetWorkspaceInvitesOutput(invites=[workspace_invite_to_dto(invite) for invite in invites])
|
||||
|
||||
@@ -17,6 +17,4 @@ async def get_workspace_members(
|
||||
|
||||
members = await self.database.get_workspace_members(workspace_id)
|
||||
|
||||
return dto.GetWorkspaceMembersOutput(
|
||||
members=[workspace_member_to_dto(member) for member in members]
|
||||
)
|
||||
return dto.GetWorkspaceMembersOutput(members=[workspace_member_to_dto(member) for member in members])
|
||||
|
||||
Reference in New Issue
Block a user