90% сеньоров плачат на таких коммитах

This commit is contained in:
Artem Tsyrulnikov
2025-12-15 19:10:41 +03:00
parent fda91cb6d8
commit 71e5a38efc
42 changed files with 403 additions and 302 deletions

View File

@@ -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)

View File

@@ -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,

View File

@@ -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)

View File

@@ -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'

View File

@@ -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)

View File

@@ -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

View File

@@ -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'

View File

@@ -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
View 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'),)

View 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'

View File

@@ -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(

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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 (

View File

@@ -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):

View File

@@ -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):

View File

@@ -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: ...

View File

@@ -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 = []

View File

@@ -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 = []

View File

@@ -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
]

View File

@@ -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

View File

@@ -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
]

View File

@@ -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:

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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)

View 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}'

View File

@@ -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,
)

View File

@@ -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=[

View File

@@ -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)

View File

@@ -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)

View File

@@ -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,
),
)

View File

@@ -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

View File

@@ -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:

View File

@@ -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,
),
)

View File

@@ -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,

View File

@@ -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])

View File

@@ -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])