feat: убираем денормализацию (антипреждевременная оптимизация)
This commit is contained in:
@@ -497,3 +497,45 @@ class Postgres(DatabaseBase):
|
||||
|
||||
async def clear_telegram_state(self, telegram_id: int) -> None:
|
||||
await domain.TelegramState.filter(telegram_id=telegram_id).delete()
|
||||
|
||||
# Count methods
|
||||
async def count_placements_by_creative(self, creative_id: uuid.UUID) -> int:
|
||||
return await domain.Placement.filter(creative_id=creative_id).count()
|
||||
|
||||
async def count_subscriptions_by_placement(self, placement_id: uuid.UUID) -> int:
|
||||
return await domain.Subscription.filter(placement_id=placement_id).count()
|
||||
|
||||
async def count_placements_by_creative_batch(self, creative_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]:
|
||||
if not creative_ids:
|
||||
return {}
|
||||
|
||||
from tortoise.functions import Count
|
||||
|
||||
results = (
|
||||
await domain.Placement.filter(creative_id__in=creative_ids)
|
||||
.group_by('creative_id')
|
||||
.annotate(count=Count('id'))
|
||||
.values('creative_id', 'count')
|
||||
)
|
||||
|
||||
counts = {row['creative_id']: row['count'] for row in results}
|
||||
return {cid: counts.get(cid, 0) for cid in creative_ids}
|
||||
|
||||
async def count_subscriptions_by_placement_batch(self, placement_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]:
|
||||
if not placement_ids:
|
||||
return {}
|
||||
|
||||
from tortoise.functions import Count
|
||||
|
||||
results = (
|
||||
await domain.Subscription.filter(placement_id__in=placement_ids)
|
||||
.group_by('placement_id')
|
||||
.annotate(count=Count('id'))
|
||||
.values('placement_id', 'count')
|
||||
)
|
||||
|
||||
counts = {row['placement_id']: row['count'] for row in results}
|
||||
return {pid: counts.get(pid, 0) for pid in placement_ids}
|
||||
|
||||
async def has_placements_for_creative(self, creative_id: uuid.UUID) -> bool:
|
||||
return await domain.Placement.filter(creative_id=creative_id).exists()
|
||||
|
||||
@@ -20,7 +20,6 @@ class Creative(TimestampedModel):
|
||||
name = fields.CharField(max_length=255)
|
||||
text = fields.TextField()
|
||||
status = fields.CharEnumField(CreativeStatus, default=CreativeStatus.ACTIVE)
|
||||
placements_count = fields.IntField(default=0)
|
||||
|
||||
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
|
||||
'models.Workspace', related_name='creatives', on_delete=fields.CASCADE, index=True
|
||||
|
||||
@@ -39,7 +39,6 @@ class Placement(TimestampedModel):
|
||||
invite_link = fields.CharField(max_length=512)
|
||||
|
||||
status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.ACTIVE)
|
||||
subscriptions_count = fields.IntField(default=0)
|
||||
|
||||
project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
|
||||
'models.Project', related_name='placements', on_delete=fields.CASCADE, index=True
|
||||
|
||||
@@ -34,3 +34,4 @@ class Subscription(TimestampedModel):
|
||||
|
||||
class Meta:
|
||||
table = 'subscription'
|
||||
unique_together = (('placement_id', 'subscriber_id'),)
|
||||
|
||||
@@ -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: ...
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user