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

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