feat: убираем денормализацию (антипреждевременная оптимизация)

This commit is contained in:
Artem Tsyrulnikov
2025-12-16 10:04:20 +03:00
parent 2339471956
commit 18b30eaf7f
26 changed files with 295 additions and 232 deletions

View File

@@ -230,6 +230,17 @@ class Database(typing.Protocol):
self, subscriber_id: UUID, project_id: UUID
) -> domain.Subscription | None: ...
# Count methods
async def count_placements_by_creative(self, creative_id: UUID) -> int: ...
async def count_subscriptions_by_placement(self, placement_id: UUID) -> int: ...
async def count_placements_by_creative_batch(self, creative_ids: list[UUID]) -> dict[UUID, int]: ...
async def count_subscriptions_by_placement_batch(self, placement_ids: list[UUID]) -> dict[UUID, int]: ...
async def has_placements_for_creative(self, creative_id: UUID) -> bool: ...
# Post methods
async def create_post(self, post: domain.Post) -> None: ...

View File

@@ -65,13 +65,17 @@ async def get_channel_analytics(
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 {}
# Batch fetch subscriptions counts
placement_ids = [p.id for p in placements]
subscriptions_counts = await self.database.count_subscriptions_by_placement_batch(placement_ids)
for p in placements:
stats = channel_stats.get(p.placement_channel_id)
if stats is None:
continue
stats.total_cost += p.cost if p.cost is not None else 0
stats.total_subscriptions += p.subscriptions_count
stats.total_subscriptions += subscriptions_counts.get(p.id, 0)
# Get views from batch data
if p.post and p.post.id in views_map:

View File

@@ -53,13 +53,17 @@ async def get_creatives_analytics(
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 {}
# Batch fetch subscriptions counts
placement_ids = [p.id for p in placements]
subscriptions_counts = await self.database.count_subscriptions_by_placement_batch(placement_ids)
for p in placements:
stats = creative_stats.get(p.creative_id)
if stats is None:
continue
stats.total_cost += p.cost if p.cost is not None else 0
stats.total_subscriptions += p.subscriptions_count
stats.total_subscriptions += subscriptions_counts.get(p.id, 0)
# Get views from batch data
if p.post and p.post.id in views_map:

View File

@@ -48,6 +48,10 @@ async def get_placements_analytics(
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 {}
# Batch fetch subscriptions counts
placement_ids = [p.id for p in placements]
subscriptions_counts = await self.database.count_subscriptions_by_placement_batch(placement_ids)
return [
dto.PlacementAnalyticsOutput(
id=p.id,
@@ -59,9 +63,9 @@ async def get_placements_analytics(
creative_name=p.creative.name,
placement_date=p.placement_date,
cost=p.cost,
subscriptions_count=p.subscriptions_count,
subscriptions_count=subscriptions_counts.get(p.id, 0),
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),
cpf=_calculate_cpf(p.cost, subscriptions_counts.get(p.id, 0)),
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

@@ -74,6 +74,10 @@ async def get_spending_analytics(
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 {}
# Batch fetch subscriptions counts
placement_ids = [p.id for p in filtered]
subscriptions_counts = await self.database.count_subscriptions_by_placement_batch(placement_ids)
# Группировка по периодам
period_data: dict[str, PeriodData] = defaultdict(PeriodData)
@@ -85,8 +89,9 @@ async def get_spending_analytics(
total_cost += p.cost
pd.cost += p.cost
total_subs += p.subscriptions_count
pd.subscriptions += p.subscriptions_count
subs_count = subscriptions_counts.get(p.id, 0)
total_subs += subs_count
pd.subscriptions += subs_count
# Get views from batch data
if p.post and p.post.id in views_map:

View File

@@ -42,5 +42,5 @@ async def create_creative(
project_channel_title=project.channel.title,
created_at=creative.created_at,
status=creative.status,
placements_count=creative.placements_count,
placements_count=0,
)

View File

@@ -21,7 +21,8 @@ async def delete_creative(self: 'Usecase', input: dto.DeleteCreativeInput) -> No
context.ensure_project_permission(domain.PermissionKey.PROJECTS_WRITE, creative.project_id)
if creative.placements_count > 0:
has_placements = await self.database.has_placements_for_creative(creative.id)
if has_placements:
log.warning('Creative %s is used in placements and cannot be deleted', input.creative_id)
raise domain.CreativeInUse(input.creative_id)

View File

@@ -20,6 +20,8 @@ async def get_creative(self: 'Usecase', input: dto.GetCreativeInput) -> dto.Crea
context.ensure_project_permission(domain.PermissionKey.PROJECTS_READ, creative.project_id)
placements_count = await self.database.count_placements_by_creative(creative.id)
return dto.CreativeOutput(
id=creative.id,
name=creative.name,
@@ -28,5 +30,5 @@ async def get_creative(self: 'Usecase', input: dto.GetCreativeInput) -> dto.Crea
project_channel_title=creative.project.channel.title,
created_at=creative.created_at,
status=creative.status,
placements_count=creative.placements_count,
placements_count=placements_count,
)

View File

@@ -24,6 +24,9 @@ async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> list[d
allowed_project_ids=allowed_project_ids,
)
creative_ids = [c.id for c in creatives]
placements_counts = await self.database.count_placements_by_creative_batch(creative_ids)
return [
dto.CreativeOutput(
id=creative.id,
@@ -33,7 +36,7 @@ async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> list[d
project_channel_title=creative.project.channel.title,
created_at=creative.created_at,
status=creative.status,
placements_count=creative.placements_count,
placements_count=placements_counts.get(creative.id, 0),
)
for creative in creatives
]

View File

@@ -35,6 +35,8 @@ async def update_creative(
await self.database.update_creative(creative)
placements_count = await self.database.count_placements_by_creative(creative.id)
return dto.CreativeOutput(
id=creative.id,
name=creative.name,
@@ -43,5 +45,5 @@ async def update_creative(
project_channel_title=creative.project.channel.title,
created_at=creative.created_at,
status=creative.status,
placements_count=creative.placements_count,
placements_count=placements_count,
)

View File

@@ -39,8 +39,6 @@ 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(
@@ -56,11 +54,7 @@ async def create_placement(
status=domain.PlacementStatus.ACTIVE,
)
async with self.database.transaction():
await self.database.create_placement(placement)
creative.placements_count += 1
await self.database.update_creative(creative)
await self.database.create_placement(placement)
return dto.PlacementOutput(
id=placement.id,
@@ -77,6 +71,6 @@ async def create_placement(
invite_link_type=placement.invite_link_type,
invite_link=placement.invite_link,
status=placement.status,
subscriptions_count=placement.subscriptions_count,
subscriptions_count=0,
created_at=placement.created_at,
)

View File

@@ -21,14 +21,4 @@ async def delete_placement(self: 'Usecase', input: dto.DeletePlacementInput) ->
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, placement.project_id)
if placement.status != domain.PlacementStatus.ACTIVE:
await self.database.delete_placement(input.placement_id)
return
async with self.database.transaction():
creative = await self.database.get_creative(input.workspace_id, placement.creative_id)
if creative and creative.placements_count > 0:
creative.placements_count -= 1
await self.database.update_creative(creative)
await self.database.delete_placement(input.placement_id)
await self.database.delete_placement(input.placement_id)

View File

@@ -12,7 +12,6 @@ log = logging.getLogger(__name__)
async def fetch_placement_post_cycle(self: 'Usecase', interval_seconds: int) -> None:
log.debug('Starting fetch_placement_post_cycle')
# Получаем все placement без привязанного поста
placements_without_post = (
await domain.Placement.filter(
post_id__isnull=True,

View File

@@ -25,18 +25,11 @@ async def get_placement(self: 'Usecase', input: dto.GetPlacementInput) -> dto.Pl
# 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
subscriptions_count = await self.database.count_subscriptions_by_placement(placement.id)
return dto.PlacementOutput(
id=placement.id,
@@ -53,6 +46,6 @@ async def get_placement(self: 'Usecase', input: dto.GetPlacementInput) -> dto.Pl
invite_link_type=placement.invite_link_type,
invite_link=placement.invite_link,
status=placement.status,
subscriptions_count=placement.subscriptions_count,
subscriptions_count=subscriptions_count,
created_at=placement.created_at,
)

View File

@@ -28,6 +28,9 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> list
allowed_project_ids=allowed_project_ids,
)
placement_ids = [p.id for p in placements]
subscriptions_counts = await self.database.count_subscriptions_by_placement_batch(placement_ids)
placement_outputs = []
for placement in placements:
ad_post_url = None
@@ -51,7 +54,7 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> list
invite_link_type=placement.invite_link_type,
invite_link=placement.invite_link,
status=placement.status,
subscriptions_count=placement.subscriptions_count,
subscriptions_count=subscriptions_counts.get(placement.id, 0),
created_at=placement.created_at,
)
)

View File

@@ -44,6 +44,8 @@ async def update_placement(
if placement.post:
ad_post_url = generate_post_url(placement.post.channel.username, placement.post.message_id)
subscriptions_count = await self.database.count_subscriptions_by_placement(placement.id)
return dto.PlacementOutput(
id=placement.id,
project_id=placement.project_id,
@@ -59,6 +61,6 @@ async def update_placement(
invite_link_type=placement.invite_link_type,
invite_link=placement.invite_link,
status=placement.status,
subscriptions_count=placement.subscriptions_count,
subscriptions_count=subscriptions_count,
created_at=placement.created_at,
)

View File

@@ -73,9 +73,18 @@ async def tg_add_project_1(self: 'Usecase', input: dto.ConnectProjectInput) -> d
channel = await self.database.get_channel(telegram_id=input.telegram_id)
if channel:
channel.title = input.title
channel.username = input.username
if input.username is not None:
channel.username = input.username
await self.database.update_channel(channel)
else:
if input.username is None:
log.warning('Cannot create channel %s without username', input.telegram_id)
await self.telegram_bot.send_message(
f'⚠️ Канал "{input.title}" не может быть подключен.\n\nУ канала отсутствует публичный username.',
input.user_telegram_id,
)
return None
channel = domain.Channel(
telegram_id=input.telegram_id,
title=input.title,

View File

@@ -60,13 +60,9 @@ async def handle_subscription(
existing_sub = await self.database.get_subscription_by_subscriber_and_placement(subscriber.id, placement.id)
if existing_sub and existing_sub.status == domain.SubscriptionStatus.UNSUBSCRIBED:
async with self.database.transaction():
existing_sub.status = domain.SubscriptionStatus.ACTIVE
existing_sub.unsubscribed_at = None
await self.database.update_subscription(existing_sub)
placement.subscriptions_count += 1
await self.database.update_placement(placement)
existing_sub.status = domain.SubscriptionStatus.ACTIVE
existing_sub.unsubscribed_at = None
await self.database.update_subscription(existing_sub)
log.info(
'Subscription reactivated: subscriber %s (telegram_id: %s) resubscribed via same placement %s',
@@ -81,11 +77,7 @@ async def handle_subscription(
subscriber_id=subscriber.id,
invite_link=invite_link,
)
async with self.database.transaction():
await self.database.create_subscription(subscription)
placement.subscriptions_count += 1
await self.database.update_placement(placement)
await self.database.create_subscription(subscription)
log.info(
'Subscription created: subscriber %s (telegram_id: %s) subscribed via placement %s (invite_link: %s)',

View File

@@ -34,19 +34,13 @@ async def handle_unsubscription(self: 'Usecase', user_telegram_id: int, channel_
return
for subscription in subscriptions:
async with self.database.transaction():
subscription.status = domain.SubscriptionStatus.UNSUBSCRIBED
subscription.unsubscribed_at = timezone.now()
await self.database.update_subscription(subscription)
subscription.status = domain.SubscriptionStatus.UNSUBSCRIBED
subscription.unsubscribed_at = timezone.now()
await self.database.update_subscription(subscription)
placement = subscription.placement
if placement.subscriptions_count > 0:
placement.subscriptions_count -= 1
await self.database.update_placement(placement)
log.info(
'Subscription marked as unsubscribed: subscriber %s (telegram_id: %s) unsubscribed from placement %s',
subscriber.id,
user_telegram_id,
placement.id,
)
log.info(
'Subscription marked as unsubscribed: subscriber %s (telegram_id: %s) unsubscribed from placement %s',
subscriber.id,
user_telegram_id,
subscription.placement_id,
)