домен обновлен

This commit is contained in:
Artem Tsyrulnikov
2026-01-07 15:11:28 +03:00
parent f6669c3f5a
commit e4199fc9c9
30 changed files with 696 additions and 1201 deletions

View File

@@ -291,11 +291,11 @@ class Postgres(DatabaseBase, Database):
)
async def get_purchase(self, workspace_id: uuid.UUID, purchase_id: uuid.UUID) -> domain.Purchase | None:
return await domain.Purchase.get_or_none(id=purchase_id, workspace_id=workspace_id)
return await domain.Purchase.get_or_none(id=purchase_id, project__workspace_id=workspace_id)
async def get_project_purchases(self, workspace_id: uuid.UUID, project_id: uuid.UUID) -> list[domain.Purchase]:
return (
await domain.Purchase.filter(workspace_id=workspace_id, project_id=project_id)
await domain.Purchase.filter(project__workspace_id=workspace_id, project_id=project_id)
.prefetch_related('channels', 'channels__channel')
.all()
)
@@ -315,6 +315,13 @@ class Postgres(DatabaseBase, Database):
.first()
)
async def get_purchase_channel_by_id(self, purchase_channel_id: uuid.UUID) -> domain.PurchaseChannel | None:
return (
await domain.PurchaseChannel.filter(id=purchase_channel_id)
.prefetch_related('channel', 'purchase', 'purchase__project')
.first()
)
async def get_purchase_channels_by_project(self, project_id: uuid.UUID) -> list[domain.PurchaseChannel]:
return (
await domain.PurchaseChannel.filter(
@@ -378,9 +385,9 @@ class Postgres(DatabaseBase, Database):
await domain.PurchaseChannel.filter(purchase_id=purchase_id, channel_id=channel_id).delete()
async def get_creative(self, workspace_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Creative | None:
return await domain.Creative.get_or_none(id=creative_id, workspace_id=workspace_id).prefetch_related(
'project', 'project__channel'
)
return await domain.Creative.get_or_none(
id=creative_id, project__workspace_id=workspace_id
).prefetch_related('project', 'project__channel')
async def update_creative(self, creative: domain.Creative) -> None:
await creative.save()
@@ -392,7 +399,7 @@ class Postgres(DatabaseBase, Database):
include_archived: bool = False,
allowed_project_ids: set[uuid.UUID] | None = None,
) -> list[domain.Creative]:
query = domain.Creative.filter(workspace_id=workspace_id)
query = domain.Creative.filter(project__workspace_id=workspace_id)
if project_id:
query = query.filter(project_id=project_id)
@@ -410,8 +417,16 @@ class Postgres(DatabaseBase, Database):
await domain.Creative.filter(id=creative_id).delete()
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', 'post', 'post__channel'
return await domain.Placement.get_or_none(
id=placement_id, project__workspace_id=workspace_id
).prefetch_related(
'project',
'project__channel',
'purchase_channel',
'purchase_channel__channel',
'creative',
'post',
'post__channel',
)
async def create_placement(self, placement: domain.Placement) -> None:
@@ -431,7 +446,7 @@ class Postgres(DatabaseBase, Database):
date_from: datetime.datetime | None = None,
date_to: datetime.datetime | None = None,
) -> list[domain.Placement]:
query = domain.Placement.filter(workspace_id=workspace_id)
query = domain.Placement.filter(project__workspace_id=workspace_id)
if project_id:
query = query.filter(project_id=project_id)
@@ -440,7 +455,7 @@ class Postgres(DatabaseBase, Database):
return []
query = query.filter(project_id__in=list(allowed_project_ids))
if placement_channel_id:
query = query.filter(placement_channel_id=placement_channel_id)
query = query.filter(purchase_channel__channel_id=placement_channel_id)
if creative_id:
query = query.filter(creative_id=creative_id)
@@ -454,7 +469,7 @@ class Postgres(DatabaseBase, Database):
return (
await query.prefetch_related(
'project', 'project__channel', 'placement_channel', 'creative', 'post', 'post__channel'
'project', 'project__channel', 'purchase_channel', 'purchase_channel__channel', 'creative', 'post', 'post__channel'
)
.order_by('-wanted_placement_date')
.all()
@@ -464,9 +479,9 @@ class Postgres(DatabaseBase, Database):
await domain.Placement.filter(id=placement_id).delete()
async def get_placement_by_invite_link(self, invite_link: str) -> domain.Placement | None:
return await domain.Placement.get_or_none(invite_link=invite_link).prefetch_related(
'project', 'project__channel', 'placement_channel', 'creative'
)
return await domain.Placement.get_or_none(
purchase_channel__invite_link=invite_link
).prefetch_related('project', 'project__channel', 'purchase_channel', 'purchase_channel__channel', 'creative')
async def create_subscription(self, subscription: domain.Subscription) -> None:
await subscription.save()

View File

@@ -41,6 +41,7 @@ __all__ = (
'WorkspaceAccessDenied',
'WorkspaceInviteNotFound',
'WorkspaceInviteAlreadyExists',
'WorkspaceInviteAlreadyProcessed',
'WorkspaceMemberAlreadyExists',
'LoginTokenNotFound',
'LoginTokenExpired',
@@ -84,6 +85,7 @@ from .error import (
UserNotFound,
WorkspaceAccessDenied,
WorkspaceInviteAlreadyExists,
WorkspaceInviteAlreadyProcessed,
WorkspaceInviteNotFound,
WorkspaceMemberAlreadyExists,
WorkspaceNotFound,

View File

@@ -10,7 +10,6 @@ from .error import CreativeInviteLinkNotFound, CreativeMediaTooLarge, CreativeMu
if TYPE_CHECKING:
from .project import Project
from .workspace import Workspace
class CreativeStatus(str, enum.Enum):
@@ -27,15 +26,11 @@ class Creative(TimestampedModel):
buttons = fields.JSONField(default=list)
status = fields.CharEnumField(CreativeStatus, default=CreativeStatus.ACTIVE)
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
'models.Workspace', related_name='creatives', on_delete=fields.CASCADE, index=True
)
project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
'models.Project', related_name='creatives', on_delete=fields.CASCADE, index=True
)
if TYPE_CHECKING:
workspace_id: UUID
project_id: UUID
class Meta:

View File

@@ -7,12 +7,10 @@ from tortoise import fields
from .base import TimestampedModel
if TYPE_CHECKING:
from .channel import Channel
from .creative import Creative
from .post import Post
from .project import Project
from .purchase import Purchase, PurchaseChannel
from .workspace import Workspace
from .purchase import PurchaseChannel
class PlacementStatus(str, enum.Enum):
@@ -31,27 +29,17 @@ class Placement(TimestampedModel):
wanted_placement_date = fields.DatetimeField(db_column='placement_date')
cost = fields.FloatField(null=True)
comment = fields.TextField(null=True)
invite_link = fields.CharField(max_length=512)
status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.ACTIVE)
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
'models.Workspace', related_name='placements', on_delete=fields.CASCADE, index=True
)
project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
'models.Project', related_name='placements', on_delete=fields.CASCADE, index=True
)
creative: fields.ForeignKeyRelation['Creative'] = fields.ForeignKeyField(
'models.Creative', related_name='placements', on_delete=fields.CASCADE, index=True
)
placement_channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField(
'models.Channel', related_name='placements', on_delete=fields.CASCADE, index=True
)
purchase: fields.ForeignKeyRelation['Purchase'] | None = fields.ForeignKeyField(
'models.Purchase', related_name='placements', on_delete=fields.CASCADE, null=True, index=True
)
purchase_channel: fields.ForeignKeyRelation['PurchaseChannel'] | None = fields.ForeignKeyField(
'models.PurchaseChannel', related_name='placements', on_delete=fields.CASCADE, null=True, index=True
purchase_channel: fields.ForeignKeyRelation['PurchaseChannel'] = fields.ForeignKeyField(
'models.PurchaseChannel', related_name='placements', on_delete=fields.CASCADE, index=True
)
post: fields.ForeignKeyRelation['Post'] | None = fields.ForeignKeyField(
@@ -59,13 +47,10 @@ class Placement(TimestampedModel):
)
if TYPE_CHECKING:
workspace_id: UUID
project_id: UUID
creative_id: UUID
placement_channel_id: UUID
purchase_id: UUID | None
post_id: UUID | None
purchase_channel_id: UUID | None
purchase_channel_id: UUID
class Meta:
table = 'placement'

View File

@@ -10,7 +10,6 @@ if TYPE_CHECKING:
from .channel import Channel
from .creative import Creative
from .project import Project
from .workspace import Workspace
class PurchaseStatus(enum.StrEnum):
@@ -54,9 +53,9 @@ class Purchase(TimestampedModel):
'models.Creative', related_name='purchases', on_delete=fields.CASCADE, index=True
)
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
'models.Workspace', related_name='purchases', on_delete=fields.CASCADE, index=True
)
if TYPE_CHECKING:
project_id: uuid.UUID
creative_id: uuid.UUID
class Meta:
table = 'purchase'

View File

@@ -47,9 +47,7 @@ class GetPlacementInput(pydantic.BaseModel):
class CreatePlacementInput(pydantic.BaseModel):
project_id: uuid.UUID
placement_channel_id: uuid.UUID
creative_id: uuid.UUID
purchase_channel_id: uuid.UUID
placement_date: datetime.datetime
cost: float | None = None
comment: str | None = None

View File

@@ -175,6 +175,8 @@ class Database(typing.Protocol):
self, purchase_channel_id: UUID, purchase_id: UUID
) -> domain.PurchaseChannel | None: ...
async def get_purchase_channel_by_id(self, purchase_channel_id: UUID) -> domain.PurchaseChannel | None: ...
async def get_purchase_channels_by_project(self, project_id: UUID) -> list[domain.PurchaseChannel]: ...
async def add_channel_to_purchase(
@@ -225,6 +227,10 @@ class Database(typing.Protocol):
date_to: datetime.datetime | None = None,
) -> list[domain.Placement]: ...
async def create_placement(self, placement: domain.Placement) -> None: ...
async def update_placement(self, placement: domain.Placement) -> None: ...
async def delete_placement(self, placement_id: UUID) -> None: ...
async def get_placement_by_invite_link(self, invite_link: str) -> domain.Placement | None: ...
@@ -398,7 +404,7 @@ class Usecase:
if purchase:
return purchase
purchase = domain.Purchase(workspace_id=project.workspace_id, project_id=project.id, creative_id=creative.id)
purchase = domain.Purchase(project_id=project.id, creative_id=creative.id)
await self.database.create_purchase(purchase)
return purchase

View File

@@ -47,8 +47,9 @@ async def get_channel_analytics(
if input.project_id is None and allowed_project_ids is not None:
channel_map: dict[UUID, domain.Channel] = {}
for placement in placements:
if placement.placement_channel:
channel_map[placement.placement_channel_id] = placement.placement_channel
purchase_channel = placement.purchase_channel
if purchase_channel and purchase_channel.channel:
channel_map[purchase_channel.channel_id] = purchase_channel.channel
channels = list(channel_map.values())
@dataclass
@@ -69,7 +70,10 @@ async def get_channel_analytics(
subscriptions_counts = await self.database.count_subscriptions_by_placement_batch(placement_ids)
for p in placements:
stats = channel_stats.get(p.placement_channel_id)
purchase_channel = p.purchase_channel
if not purchase_channel:
continue
stats = channel_stats.get(purchase_channel.channel_id)
if stats is None:
continue

View File

@@ -131,19 +131,21 @@ async def get_overview_analytics(
project_meta: dict[UUID, domain.Project] = {}
for placement in current_placements:
placement_date = placement.wanted_placement_date.date()
placement_day = placement.wanted_placement_date.date()
project_meta[placement.project_id] = placement.project
cost_value = placement.cost or 0.0
cost_per_day[placement_date] += cost_value
cost_per_day[placement_day] += cost_value
project_spending_map[placement.project_id] += cost_value
subs = subs_per_placement_current.get(placement.id, 0)
channel_id = placement.placement_channel_id
if channel_id not in channel_stats:
channel_stats[channel_id] = ChannelAggregate(channel=placement.placement_channel)
channel_stats[channel_id].total_cost += cost_value
channel_stats[channel_id].total_subs += subs
purchase_channel = placement.purchase_channel
if purchase_channel:
channel_id = purchase_channel.channel_id
if channel_id not in channel_stats:
channel_stats[channel_id] = ChannelAggregate(channel=purchase_channel.channel)
channel_stats[channel_id].total_cost += cost_value
channel_stats[channel_id].total_subs += subs
start_date = input.date_from.date()
end_date = input.date_to.date()

View File

@@ -57,8 +57,8 @@ async def get_placements_analytics(
id=p.id,
project_id=p.project_id,
project_channel_title=p.project.channel.title,
placement_channel_id=p.placement_channel_id,
placement_channel_title=p.placement_channel.title,
placement_channel_id=p.purchase_channel.channel_id,
placement_channel_title=p.purchase_channel.channel.title,
creative_id=p.creative_id,
creative_name=p.creative.name,
placement_date=p.wanted_placement_date,

View File

@@ -47,4 +47,5 @@ async def attach_channel_to_workspace(
title=channel.title,
username=channel.username,
status=project.status,
purchase_invite_type_default=project.purchase_invite_type_default,
)

View File

@@ -43,7 +43,6 @@ async def create_creative(
buttons=[button.model_dump() for button in input.buttons],
status=domain.CreativeStatus.ACTIVE,
project_id=project.id,
workspace_id=workspace_id,
)
await self.database.create_creative(creative)

View File

@@ -1,84 +0,0 @@
import logging
import uuid
from typing import TYPE_CHECKING
import domain.purchase
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def create_placement(
self: 'Usecase', input: dto.CreatePlacementInput, user_id: uuid.UUID, workspace_id: uuid.UUID
) -> dto.PlacementOutput:
await self.ensure_workspace_permission(
workspace_id,
user_id,
domain.PermissionKey.PLACEMENTS_WRITE,
for_project_id=input.project_id,
)
project = await self.database.get_project(workspace_id, project_id=input.project_id)
if not project:
raise domain.ProjectNotFound(input.project_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)
creative = await self.database.get_creative(workspace_id, input.creative_id)
if not creative:
raise domain.CreativeNotFound(input.creative_id)
if creative.project_id != project.id:
raise domain.CreativeNotFound(input.creative_id)
# Берем тип ссылки из настроек проекта
requires_approval = project.purchase_invite_type_default == domain.purchase.InviteLinkType.APPROVAL
if project.channel.telegram_id is None:
raise domain.ChannelNotFound(project.channel.id)
invite_link = await self.telegram_bot.create_chat_invite_link(project.channel.telegram_id, requires_approval)
log.info(
'Created invite link for project=%s channel_telegram_id=%s requires_approval=%s invite_link=%s',
project.id,
project.channel.telegram_id,
requires_approval,
invite_link,
)
placement = domain.Placement(
project_id=input.project_id,
placement_channel_id=input.placement_channel_id,
creative_id=input.creative_id,
workspace_id=workspace_id,
placement_date=input.placement_date,
cost=input.cost,
comment=input.comment,
invite_link=invite_link,
status=domain.PlacementStatus.ACTIVE,
)
await self.database.create_placement(placement)
return dto.PlacementOutput(
id=placement.id,
project_id=placement.project_id,
project_channel_title=project.channel.title,
placement_channel_id=placement.placement_channel_id,
placement_channel_title=placement_channel.title,
creative_id=placement.creative_id,
creative_name=creative.name,
placement_date=placement.wanted_placement_date,
cost=placement.cost,
comment=placement.comment,
ad_post_url=None,
invite_link_type=project.purchase_invite_type_default,
invite_link=placement.invite_link,
status=placement.status,
subscriptions_count=0,
created_at=placement.created_at,
)

View File

@@ -1,24 +0,0 @@
import logging
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def delete_placement(self: 'Usecase', input: dto.DeletePlacementInput) -> None:
context = await self.ensure_workspace_permission(
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_WRITE
)
placement = await self.database.get_placement(input.workspace_id, input.placement_id)
if not placement:
log.warning('Placement %s not found for user %s', input.placement_id, input.user_id)
raise domain.PlacementNotFound(input.placement_id)
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, placement.project_id)
await self.database.delete_placement(input.placement_id)

View File

@@ -44,12 +44,8 @@ async def fetch_placement_post_cycle(self: 'Usecase', interval_seconds: int) ->
wanted_placement_date=post.created_at,
cost=None,
comment=None,
invite_link=purchase_channel.invite_link,
workspace_id=purchase_channel.purchase.workspace_id,
project_id=purchase_channel.purchase.project_id,
creative_id=purchase_channel.purchase.creative_id,
placement_channel_id=purchase_channel.channel_id,
purchase_id=purchase_channel.purchase_id,
purchase_channel_id=purchase_channel.id,
post=post,
status=domain.PlacementStatus.ACTIVE,

View File

@@ -22,6 +22,10 @@ async def get_placement(self: 'Usecase', input: dto.GetPlacementInput) -> dto.Pl
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, placement.project_id)
ad_post_url = placement.post.url if placement.post else None
purchase_channel = placement.purchase_channel
if not purchase_channel or not purchase_channel.channel:
log.warning('Placement %s missing purchase channel data', placement.id)
raise domain.PurchaseChannelNotFound()
subscriptions_count = await self.database.count_subscriptions_by_placement(placement.id)
@@ -29,17 +33,19 @@ async def get_placement(self: 'Usecase', input: dto.GetPlacementInput) -> dto.Pl
id=placement.id,
project_id=placement.project_id,
project_channel_title=placement.project.channel.title,
placement_channel_id=placement.placement_channel_id,
placement_channel_title=placement.placement_channel.title,
placement_channel_id=purchase_channel.channel_id,
placement_channel_title=purchase_channel.channel.title,
creative_id=placement.creative_id,
creative_name=placement.creative.name,
placement_date=placement.wanted_placement_date,
cost=placement.cost,
comment=placement.comment,
ad_post_url=ad_post_url,
invite_link_type=placement.project.purchase_invite_type_default,
invite_link=placement.invite_link,
invite_link_type=purchase_channel.invite_link_type,
invite_link=purchase_channel.invite_link,
status=placement.status,
subscriptions_count=subscriptions_count,
purchase_id=purchase_channel.purchase_id,
purchase_channel_id=purchase_channel.id,
created_at=placement.created_at,
)

View File

@@ -1,3 +1,4 @@
import logging
from typing import TYPE_CHECKING
from src import domain, dto
@@ -5,6 +6,8 @@ from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> list[dto.PlacementOutput]:
context = await self.ensure_workspace_permission(
@@ -32,24 +35,30 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> list
placement_outputs = []
for placement in placements:
ad_post_url = placement.post.url if placement.post else None
purchase_channel = placement.purchase_channel
if not purchase_channel or not purchase_channel.channel:
log.warning('Placement %s missing purchase channel data', placement.id)
continue
placement_outputs.append(
dto.PlacementOutput(
id=placement.id,
project_id=placement.project_id,
project_channel_title=placement.project.channel.title,
placement_channel_id=placement.placement_channel_id,
placement_channel_title=placement.placement_channel.title,
placement_channel_id=purchase_channel.channel_id,
placement_channel_title=purchase_channel.channel.title,
creative_id=placement.creative_id,
creative_name=placement.creative.name,
placement_date=placement.wanted_placement_date,
cost=placement.cost,
comment=placement.comment,
ad_post_url=ad_post_url,
invite_link_type=placement.project.purchase_invite_type_default,
invite_link=placement.invite_link,
invite_link_type=purchase_channel.invite_link_type,
invite_link=purchase_channel.invite_link,
status=placement.status,
subscriptions_count=subscriptions_counts.get(placement.id, 0),
purchase_id=purchase_channel.purchase_id,
purchase_channel_id=purchase_channel.id,
created_at=placement.created_at,
)
)

View File

@@ -1,61 +0,0 @@
import logging
import uuid
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def update_placement(
self: 'Usecase',
placement_id: uuid.UUID,
input: dto.UpdatePlacementInput,
user_id: uuid.UUID,
workspace_id: uuid.UUID,
) -> dto.PlacementOutput:
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:
log.warning('Placement %s not found for user %s', placement_id, user_id)
raise domain.PlacementNotFound(placement_id)
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, placement.project_id)
if input.placement_date is not None:
placement.wanted_placement_date = input.placement_date
if input.cost is not None:
placement.cost = input.cost
if input.comment is not None:
placement.comment = input.comment
if input.status is not None:
placement.status = input.status
await self.database.update_placement(placement)
ad_post_url = placement.post.url if placement.post else None
subscriptions_count = await self.database.count_subscriptions_by_placement(placement.id)
return dto.PlacementOutput(
id=placement.id,
project_id=placement.project_id,
project_channel_title=placement.project.channel.title,
placement_channel_id=placement.placement_channel_id,
placement_channel_title=placement.placement_channel.title,
creative_id=placement.creative_id,
creative_name=placement.creative.name,
placement_date=placement.wanted_placement_date,
cost=placement.cost,
comment=placement.comment,
ad_post_url=ad_post_url,
invite_link_type=placement.project.purchase_invite_type_default,
invite_link=placement.invite_link,
status=placement.status,
subscriptions_count=subscriptions_count,
created_at=placement.created_at,
)

View File

@@ -99,7 +99,6 @@ async def create_purchase(
details = input.details
purchase = domain.Purchase(
workspace_id=workspace_id,
project_id=project.id,
creative_id=creative.id,
placement_at=details.placement_at if details else None,