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

@@ -1,3 +1,5 @@
# ruff: noqa
# mypy: ignore-errors
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
@@ -244,74 +246,74 @@ async def downgrade(db: BaseDBAsyncClient) -> str:
MODELS_STATE = (
"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"
"37jnCt9x2t7SO8Gzcb/f4/gXYk2A=="
'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'
'37jnCt9x2t7SO8Gzcb/f4/gXYk2A=='
)

View File

@@ -1,90 +0,0 @@
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
COMMENT ON COLUMN "placement"."status" IS 'ACTIVE: active
ARCHIVED: archived';"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
COMMENT ON COLUMN "placement"."status" IS 'PENDING: pending
ACTIVE: active
DELETED: deleted
ARCHIVED: archived';"""
MODELS_STATE = (
"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"
)

View File

@@ -0,0 +1,98 @@
# ruff: noqa
# mypy: ignore-errors
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "creative" DROP COLUMN "placements_count";
ALTER TABLE "placement" DROP COLUMN "subscriptions_count";
COMMENT ON COLUMN "placement"."status" IS 'ACTIVE: active
ARCHIVED: archived';
CREATE UNIQUE INDEX IF NOT EXISTS "uid_subscriptio_placeme_8fcb8f" ON "subscription" ("placement_id", "subscriber_id");"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
DROP INDEX IF EXISTS "uid_subscriptio_placeme_8fcb8f";
ALTER TABLE "creative" ADD "placements_count" INT NOT NULL DEFAULT 0;
ALTER TABLE "placement" ADD "subscriptions_count" INT NOT NULL DEFAULT 0;
COMMENT ON COLUMN "placement"."status" IS 'PENDING: pending
ACTIVE: active
DELETED: deleted
ARCHIVED: archived';"""
MODELS_STATE = (
'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'
'aq33Ha3tI7wbNxv9/j9vKFN0'
)

View File

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

View File

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

View File

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

View File

@@ -34,3 +34,4 @@ class Subscription(TimestampedModel):
class Meta:
table = 'subscription'
unique_together = (('placement_id', 'subscriber_id'),)

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,12 +54,8 @@ 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)
return dto.PlacementOutput(
id=placement.id,
project_id=placement.project_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)

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
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,14 +60,10 @@ 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)
log.info(
'Subscription reactivated: subscriber %s (telegram_id: %s) resubscribed via same placement %s',
subscriber.id,
@@ -81,12 +77,8 @@ 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)
log.info(
'Subscription created: subscriber %s (telegram_id: %s) subscribed via placement %s (invite_link: %s)',
subscriber.id,

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)
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,
subscription.placement_id,
)