размещения ручки
This commit is contained in:
@@ -382,6 +382,14 @@ class Postgres(DatabaseBase):
|
||||
'project', 'project__channel', 'channel', 'creative'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def update_placement(placement: domain.Placement) -> None:
|
||||
await placement.save()
|
||||
|
||||
@staticmethod
|
||||
async def delete_placement(placement_id: uuid.UUID) -> None:
|
||||
await domain.Placement.filter(id=placement_id).delete()
|
||||
|
||||
@staticmethod
|
||||
async def get_project_placements(
|
||||
workspace_id: uuid.UUID, project_id: uuid.UUID, include_archived: bool = False
|
||||
@@ -391,10 +399,12 @@ class Postgres(DatabaseBase):
|
||||
if not include_archived:
|
||||
query = query.filter(
|
||||
status__in=[
|
||||
domain.PlacementStatus.PLANNED,
|
||||
domain.PlacementStatus.APPROVED,
|
||||
domain.PlacementStatus.IN_PROGRESS,
|
||||
domain.PlacementStatus.COMPLETED,
|
||||
domain.PlacementStatus.NO_STATUS,
|
||||
domain.PlacementStatus.WRITE,
|
||||
domain.PlacementStatus.WAITING_RESPONSE,
|
||||
domain.PlacementStatus.TERMS_APPROVAL,
|
||||
domain.PlacementStatus.TO_PAY,
|
||||
domain.PlacementStatus.PAID,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -418,6 +428,10 @@ class Postgres(DatabaseBase):
|
||||
'post__channel',
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def count_placement_posts_by_placement(placement_id: uuid.UUID) -> int:
|
||||
return await domain.PlacementPost.filter(placement_id=placement_id).count()
|
||||
|
||||
@staticmethod
|
||||
async def get_workspace_placement_posts(
|
||||
workspace_id: uuid.UUID,
|
||||
@@ -450,9 +464,6 @@ class Postgres(DatabaseBase):
|
||||
if date_to:
|
||||
query = query.filter(created_at__lte=date_to)
|
||||
|
||||
if not include_archived:
|
||||
query = query.filter(status=domain.PlacementPostStatus.ACTIVE)
|
||||
|
||||
return (
|
||||
await query.prefetch_related(
|
||||
'placement',
|
||||
@@ -481,9 +492,6 @@ class Postgres(DatabaseBase):
|
||||
placement_id__in=placement_ids,
|
||||
)
|
||||
|
||||
if not include_archived:
|
||||
query = query.filter(status=domain.PlacementPostStatus.ACTIVE)
|
||||
|
||||
return (
|
||||
await query.prefetch_related(
|
||||
'placement',
|
||||
|
||||
@@ -61,3 +61,51 @@ async def get_placement(
|
||||
placement_id=placement_id,
|
||||
)
|
||||
return await deps.get_usecase().get_placement_user(input=input)
|
||||
|
||||
|
||||
@placements_user_router.patch('/{placement_id}')
|
||||
async def update_placement(
|
||||
placement_id: uuid.UUID,
|
||||
request: dto.UpdatePlacementInput,
|
||||
workspace_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
||||
) -> dto.PlacementWithPostsOutput:
|
||||
return await deps.get_usecase().update_placement(
|
||||
placement_id=placement_id,
|
||||
input=request,
|
||||
workspace_id=workspace_id,
|
||||
project_id=project_id,
|
||||
user_id=current_user.user_id,
|
||||
)
|
||||
|
||||
|
||||
@placements_user_router.post('/{placement_id}/creative')
|
||||
async def build_placement_creative(
|
||||
placement_id: uuid.UUID,
|
||||
workspace_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
||||
) -> dto.CreativePreviewOutput:
|
||||
return await deps.get_usecase().build_placement_creative(
|
||||
placement_id=placement_id,
|
||||
workspace_id=workspace_id,
|
||||
project_id=project_id,
|
||||
user_id=current_user.user_id,
|
||||
)
|
||||
|
||||
|
||||
@placements_user_router.delete('/{placement_id}')
|
||||
async def delete_placement(
|
||||
placement_id: uuid.UUID,
|
||||
workspace_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
||||
) -> None:
|
||||
input = dto.DeletePlacementInput(
|
||||
user_id=current_user.user_id,
|
||||
workspace_id=workspace_id,
|
||||
project_id=project_id,
|
||||
placement_id=placement_id,
|
||||
)
|
||||
await deps.get_usecase().delete_placement(input=input)
|
||||
|
||||
@@ -20,7 +20,6 @@ __all__ = (
|
||||
'PlacementStatus',
|
||||
'PlacementType',
|
||||
'PlacementPost',
|
||||
'PlacementPostStatus',
|
||||
'Creative',
|
||||
'replace_invite_link_with_tag',
|
||||
'validate_media_size',
|
||||
@@ -34,7 +33,6 @@ __all__ = (
|
||||
'SubscriptionStatus',
|
||||
'InviteLinkType',
|
||||
'CostType',
|
||||
'PostViewsAvailability',
|
||||
'LoginToken',
|
||||
'UserNotFound',
|
||||
'WorkspaceNotFound',
|
||||
@@ -104,7 +102,7 @@ from .placement import (
|
||||
PlacementStatus,
|
||||
PlacementType,
|
||||
)
|
||||
from .placement_post import PlacementPost, PlacementPostStatus, PostViewsAvailability
|
||||
from .placement_post import PlacementPost
|
||||
from .post import Post
|
||||
from .post_views_history import PostViewsHistory
|
||||
from .project import Project, ProjectStatus
|
||||
|
||||
@@ -59,6 +59,13 @@ def PlacementPostNotFound(placement_post_id: uuid.UUID | None = None) -> HTTPExc
|
||||
return HTTPException(status.HTTP_404_NOT_FOUND, f'PlacementPost {placement_post_id} not found')
|
||||
|
||||
|
||||
def PlacementHasPosts(placement_id: uuid.UUID) -> HTTPException:
|
||||
return HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
f'Placement {placement_id} has placement_posts and cannot remove creative',
|
||||
)
|
||||
|
||||
|
||||
def ChannelAlreadyExists(telegram_id: int) -> HTTPException:
|
||||
return HTTPException(status.HTTP_409_CONFLICT, f'Channel {telegram_id} already exists in the system')
|
||||
|
||||
|
||||
@@ -20,11 +20,16 @@ class CostType(enum.StrEnum):
|
||||
|
||||
|
||||
class PlacementStatus(enum.StrEnum):
|
||||
PLANNED = 'planned'
|
||||
APPROVED = 'approved'
|
||||
REJECTED = 'rejected'
|
||||
IN_PROGRESS = 'in_progress'
|
||||
COMPLETED = 'completed'
|
||||
NO_STATUS = 'Без статуса'
|
||||
WRITE = 'Написать'
|
||||
WAITING_RESPONSE = 'Ждём ответа'
|
||||
TERMS_APPROVAL = 'Согласование условий'
|
||||
TO_PAY = 'Оплатить'
|
||||
PAID = 'Оплачено'
|
||||
CANCELED = 'Отмена'
|
||||
PRICE_NOT_OK = 'Не подходит цена'
|
||||
NOT_RELEVANT = 'Неактуально'
|
||||
NO_RESPONSE = 'Не отвечает'
|
||||
|
||||
|
||||
class PlacementType(enum.StrEnum):
|
||||
@@ -38,7 +43,7 @@ class InviteLinkType(enum.StrEnum):
|
||||
|
||||
|
||||
class Placement(TimestampedModel):
|
||||
status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.PLANNED)
|
||||
status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.NO_STATUS)
|
||||
placement_at = fields.DatetimeField(null=True)
|
||||
payment_at = fields.DatetimeField(null=True)
|
||||
cost_type = fields.CharEnumField(CostType, null=True, max_length=8)
|
||||
@@ -48,14 +53,14 @@ class Placement(TimestampedModel):
|
||||
format = fields.TextField(null=True)
|
||||
comment = fields.TextField(null=True)
|
||||
|
||||
invite_link = fields.CharField(max_length=512)
|
||||
invite_link = fields.CharField(max_length=512, null=True)
|
||||
invite_link_type = fields.CharEnumField(InviteLinkType)
|
||||
|
||||
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
|
||||
'models.Creative', related_name='placements', on_delete=fields.CASCADE, null=True, index=True
|
||||
)
|
||||
channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField(
|
||||
'models.Channel', related_name='placements', on_delete=fields.CASCADE, index=True
|
||||
@@ -63,7 +68,7 @@ class Placement(TimestampedModel):
|
||||
|
||||
if TYPE_CHECKING:
|
||||
project_id: uuid.UUID
|
||||
creative_id: uuid.UUID
|
||||
creative_id: uuid.UUID | None
|
||||
channel_id: uuid.UUID
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import enum
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
@@ -7,50 +6,21 @@ from tortoise import fields
|
||||
from .base import TimestampedModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .creative import Creative
|
||||
from .placement import Placement
|
||||
from .post import Post
|
||||
from .project import Project
|
||||
|
||||
__all__ = ['PlacementPost', 'PlacementPostStatus', 'PostViewsAvailability']
|
||||
|
||||
|
||||
class PlacementPostStatus(str, enum.Enum):
|
||||
ACTIVE = 'active' # Пост найден и отслеживается
|
||||
ARCHIVED = 'archived' # Вручную архивировано
|
||||
|
||||
|
||||
class PostViewsAvailability(str, enum.Enum):
|
||||
UNKNOWN = 'unknown' # Ещё не проверялось
|
||||
AVAILABLE = 'available' # Просмотры доступны
|
||||
UNAVAILABLE = 'unavailable' # Нет доступа / битая ссылка / приватный канал
|
||||
MANUAL = 'manual' # Просмотры вводятся вручную
|
||||
__all__ = ['PlacementPost']
|
||||
|
||||
|
||||
class PlacementPost(TimestampedModel):
|
||||
wanted_placement_date = fields.DatetimeField(db_column='placement_date')
|
||||
cost = fields.FloatField(null=True)
|
||||
comment = fields.TextField(null=True)
|
||||
|
||||
status = fields.CharEnumField(PlacementPostStatus, default=PlacementPostStatus.ACTIVE)
|
||||
|
||||
project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
|
||||
'models.Project', related_name='placement_posts', on_delete=fields.CASCADE, index=True
|
||||
)
|
||||
creative: fields.ForeignKeyRelation['Creative'] = fields.ForeignKeyField(
|
||||
'models.Creative', related_name='placement_posts', on_delete=fields.CASCADE, index=True
|
||||
)
|
||||
placement: fields.ForeignKeyRelation['Placement'] = fields.ForeignKeyField(
|
||||
'models.Placement', related_name='placement_posts', on_delete=fields.CASCADE, index=True
|
||||
)
|
||||
|
||||
post: fields.ForeignKeyRelation['Post'] | None = fields.ForeignKeyField(
|
||||
'models.Post', related_name='placement_posts', on_delete=fields.SET_NULL, null=True, index=True
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
project_id: UUID
|
||||
creative_id: UUID
|
||||
post_id: UUID | None
|
||||
placement_id: UUID
|
||||
|
||||
|
||||
@@ -25,10 +25,14 @@ __all__ = (
|
||||
'GetPlacementsInput',
|
||||
'GetPlacementsOutput',
|
||||
'GetPlacementInput',
|
||||
'UpdatePlacementInput',
|
||||
'DeletePlacementInput',
|
||||
'PlacementPostOutput',
|
||||
'PostOutput',
|
||||
'PlacementWithPostsOutput',
|
||||
'CreativeButton',
|
||||
'CreativeOutput',
|
||||
'CreativePreviewOutput',
|
||||
'GetCreativesInput',
|
||||
'GetCreativesOutput',
|
||||
'GetCreativeInput',
|
||||
@@ -123,6 +127,7 @@ from .creative import (
|
||||
CreateCreativeInput,
|
||||
CreativeButton,
|
||||
CreativeOutput,
|
||||
CreativePreviewOutput,
|
||||
DeleteCreativeInput,
|
||||
GetCreativeInput,
|
||||
GetCreativesInput,
|
||||
@@ -146,13 +151,16 @@ from .purchase import (
|
||||
CostInfo,
|
||||
CreatePlacementChannelInput,
|
||||
CreatePlacementsInput,
|
||||
DeletePlacementInput,
|
||||
GetPlacementInput,
|
||||
GetPlacementsInput,
|
||||
GetPlacementsOutput,
|
||||
PlacementDetails,
|
||||
PlacementOutput,
|
||||
PlacementPostOutput,
|
||||
PostOutput,
|
||||
PlacementWithPostsOutput,
|
||||
UpdatePlacementInput,
|
||||
)
|
||||
from .user import UserOutput
|
||||
from .validate_login_token import ValidateLoginTokenInput, ValidateLoginTokenOutput
|
||||
|
||||
@@ -25,6 +25,15 @@ class CreativeOutput(pydantic.BaseModel):
|
||||
placements_count: int
|
||||
|
||||
|
||||
class CreativePreviewOutput(pydantic.BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
text: str
|
||||
media_type: str | None
|
||||
media_file_id: str | None
|
||||
buttons: list[CreativeButton]
|
||||
|
||||
|
||||
class GetCreativesInput(pydantic.BaseModel):
|
||||
user_id: uuid.UUID
|
||||
workspace_id: uuid.UUID
|
||||
|
||||
@@ -4,9 +4,9 @@ import uuid
|
||||
import pydantic
|
||||
|
||||
from src.domain.placement import CostType, InviteLinkType, PlacementStatus, PlacementType
|
||||
from src.domain.placement_post import PlacementPostStatus
|
||||
|
||||
from .channel import ChannelOutput
|
||||
from .creative import CreativePreviewOutput
|
||||
|
||||
|
||||
class CostInfo(pydantic.BaseModel):
|
||||
@@ -22,41 +22,41 @@ class PlacementDetails(pydantic.BaseModel):
|
||||
placement_type: PlacementType | None = None
|
||||
format: str | None = None
|
||||
comment: str | None = None
|
||||
creative_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class PlacementOutput(pydantic.BaseModel):
|
||||
id: uuid.UUID
|
||||
status: PlacementStatus
|
||||
creative_id: uuid.UUID | None = None
|
||||
comment: str | None = None
|
||||
invite_link: str
|
||||
invite_link: str | None
|
||||
invite_link_type: InviteLinkType
|
||||
channel: ChannelOutput
|
||||
details: PlacementDetails | None = None
|
||||
|
||||
|
||||
class PlacementPostOutput(pydantic.BaseModel):
|
||||
class PostOutput(pydantic.BaseModel):
|
||||
id: uuid.UUID
|
||||
project_id: uuid.UUID
|
||||
project_channel_title: str | None
|
||||
placement_channel_id: uuid.UUID # Канал, где размещен пост
|
||||
placement_channel_title: str | None
|
||||
creative_id: uuid.UUID
|
||||
creative_name: str
|
||||
placement_date: datetime.datetime # wanted_placement_date
|
||||
cost: float | None
|
||||
comment: str | None
|
||||
ad_post_url: str | None
|
||||
invite_link_type: InviteLinkType
|
||||
invite_link: str
|
||||
status: PlacementPostStatus
|
||||
subscriptions_count: int
|
||||
placement_id: uuid.UUID # Ссылка на Placement
|
||||
placement: PlacementOutput
|
||||
message_id: int
|
||||
text: str
|
||||
url: str | None
|
||||
deleted_from_channel_at: datetime.datetime | None
|
||||
created_at: datetime.datetime
|
||||
updated_at: datetime.datetime
|
||||
|
||||
|
||||
class PlacementPostOutput(pydantic.BaseModel):
|
||||
subscriptions_count: int
|
||||
views_count: int | None
|
||||
created_at: datetime.datetime
|
||||
post: PostOutput
|
||||
|
||||
|
||||
class PlacementWithPostsOutput(PlacementOutput):
|
||||
placement_posts: list[PlacementPostOutput] = pydantic.Field(default_factory=list)
|
||||
placement_post: PlacementPostOutput | None = None
|
||||
|
||||
|
||||
|
||||
|
||||
class CreatePlacementChannelInput(pydantic.BaseModel):
|
||||
@@ -71,7 +71,7 @@ class CreatePlacementChannelInput(pydantic.BaseModel):
|
||||
class CreatePlacementsInput(pydantic.BaseModel):
|
||||
"""Input для создания нескольких placements (бывший CreatePurchaseInput)"""
|
||||
|
||||
creative_id: uuid.UUID
|
||||
creative_id: uuid.UUID | None = None
|
||||
channels: list[CreatePlacementChannelInput]
|
||||
details: PlacementDetails | None = None # Общие детали для всех placements
|
||||
|
||||
@@ -91,3 +91,22 @@ class GetPlacementInput(pydantic.BaseModel):
|
||||
workspace_id: uuid.UUID
|
||||
project_id: uuid.UUID
|
||||
placement_id: uuid.UUID
|
||||
|
||||
|
||||
class UpdatePlacementInput(pydantic.BaseModel):
|
||||
status: PlacementStatus | None = None
|
||||
comment: str | None = None
|
||||
creative_id: uuid.UUID | None = None
|
||||
placement_at: datetime.datetime | None = None
|
||||
payment_at: datetime.datetime | None = None
|
||||
cost: CostInfo | None = None
|
||||
cost_before_bargain: float | None = None
|
||||
placement_type: PlacementType | None = None
|
||||
format: str | None = None
|
||||
|
||||
|
||||
class DeletePlacementInput(pydantic.BaseModel):
|
||||
user_id: uuid.UUID
|
||||
workspace_id: uuid.UUID
|
||||
project_id: uuid.UUID
|
||||
placement_id: uuid.UUID
|
||||
|
||||
@@ -38,9 +38,12 @@ from .project.move_project_to_workspace import move_project_to_workspace
|
||||
from .project.tg_add_project import tg_add_project
|
||||
from .project.update_project_invite_link_type import update_project_invite_link_type
|
||||
from .project.update_project_permissions import update_project_permissions
|
||||
from .purchase.build_placement_creative import build_placement_creative
|
||||
from .purchase.create_placements import create_placements
|
||||
from .purchase.delete_placement import delete_placement
|
||||
from .purchase.get_placement import get_placement_user
|
||||
from .purchase.get_placements import get_placements
|
||||
from .purchase.update_placement import update_placement
|
||||
from .subscription.handle_subscription import handle_subscription
|
||||
from .subscription.handle_unsubscription import handle_unsubscription
|
||||
from .views.get_views_history import get_views_history
|
||||
@@ -167,6 +170,9 @@ class Usecase:
|
||||
create_placements = create_placements
|
||||
get_placements = get_placements
|
||||
get_placement_user = get_placement_user
|
||||
build_placement_creative = build_placement_creative
|
||||
update_placement = update_placement
|
||||
delete_placement = delete_placement
|
||||
# Creative use cases
|
||||
get_creatives = get_creatives
|
||||
get_creative = get_creative
|
||||
|
||||
@@ -11,6 +11,11 @@ if TYPE_CHECKING:
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_cost(placement_post: domain.PlacementPost) -> float:
|
||||
placement = placement_post.placement
|
||||
return placement.cost_value if placement and placement.cost_value is not None else 0.0
|
||||
|
||||
|
||||
async def get_channel_analytics(
|
||||
self: 'Usecase', input: dto.GetChannelAnalyticsInput
|
||||
) -> list[dto.ChannelAnalyticsOutput]:
|
||||
@@ -69,7 +74,7 @@ async def get_channel_analytics(
|
||||
if stats is None:
|
||||
continue
|
||||
|
||||
stats.total_cost += placement_post.cost if placement_post.cost is not None else 0
|
||||
stats.total_cost += _get_cost(placement_post)
|
||||
stats.total_subscriptions += subscriptions_counts.get(placement_post.id, 0)
|
||||
|
||||
# Get views from batch data
|
||||
|
||||
@@ -11,6 +11,11 @@ if TYPE_CHECKING:
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_cost(placement_post: domain.PlacementPost) -> float:
|
||||
placement = placement_post.placement
|
||||
return placement.cost_value if placement and placement.cost_value is not None else 0.0
|
||||
|
||||
|
||||
async def get_creatives_analytics(
|
||||
self: 'Usecase', input: dto.GetCreativesAnalyticsInput
|
||||
) -> list[dto.CreativeAnalyticsOutput]:
|
||||
@@ -57,17 +62,20 @@ async def get_creatives_analytics(
|
||||
placement_ids = [p.id for p in placements]
|
||||
subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_ids)
|
||||
|
||||
for p in placements:
|
||||
stats = creative_stats.get(p.creative_id)
|
||||
for placement_post in placements:
|
||||
placement = placement_post.placement
|
||||
if not placement or not placement.creative_id:
|
||||
continue
|
||||
stats = creative_stats.get(placement.creative_id)
|
||||
if stats is None:
|
||||
continue
|
||||
|
||||
stats.total_cost += p.cost if p.cost is not None else 0
|
||||
stats.total_subscriptions += subscriptions_counts.get(p.id, 0)
|
||||
stats.total_cost += _get_cost(placement_post)
|
||||
stats.total_subscriptions += subscriptions_counts.get(placement_post.id, 0)
|
||||
|
||||
# Get views from batch data
|
||||
if p.post and p.post.id in views_map:
|
||||
views_count = views_map[p.post.id][0]
|
||||
if placement_post.post and placement_post.post.id in views_map:
|
||||
views_count = views_map[placement_post.post.id][0]
|
||||
stats.total_views += views_count
|
||||
|
||||
stats.placements_count += 1
|
||||
|
||||
@@ -29,6 +29,20 @@ class ChannelAggregate:
|
||||
total_subs: int = 0
|
||||
|
||||
|
||||
def _get_placement_date(placement_post: domain.PlacementPost) -> datetime.datetime:
|
||||
placement = placement_post.placement
|
||||
if placement and placement.placement_at:
|
||||
return placement.placement_at
|
||||
if placement_post.post and placement_post.post.created_at:
|
||||
return placement_post.post.created_at
|
||||
return placement_post.created_at
|
||||
|
||||
|
||||
def _get_cost(placement_post: domain.PlacementPost) -> float:
|
||||
placement = placement_post.placement
|
||||
return placement.cost_value if placement and placement.cost_value is not None else 0.0
|
||||
|
||||
|
||||
async def get_overview_analytics(
|
||||
self: 'Usecase', input: dto.GetOverviewAnalyticsInput
|
||||
) -> dto.GetOverviewAnalyticsOutput:
|
||||
@@ -68,7 +82,7 @@ async def get_overview_analytics(
|
||||
previous_placements: list[domain.PlacementPost] = []
|
||||
|
||||
for placement_post in placements:
|
||||
placement_date = placement_post.wanted_placement_date
|
||||
placement_date = _get_placement_date(placement_post)
|
||||
if placement_date >= input.date_from and placement_date <= input.date_to:
|
||||
current_placements.append(placement_post)
|
||||
elif placement_date >= previous_period_start and placement_date < input.date_from:
|
||||
@@ -105,8 +119,7 @@ async def get_overview_analytics(
|
||||
total_views = 0
|
||||
|
||||
for placement_post in placement_list:
|
||||
if placement_post.cost is not None:
|
||||
total_cost += placement_post.cost
|
||||
total_cost += _get_cost(placement_post)
|
||||
|
||||
subs = subs_per_placement.get(placement_post.id, 0)
|
||||
total_subscriptions += subs
|
||||
@@ -131,22 +144,23 @@ async def get_overview_analytics(
|
||||
project_meta: dict[UUID, domain.Project] = {}
|
||||
|
||||
for placement_post in current_placements:
|
||||
placement_day = placement_post.wanted_placement_date.date()
|
||||
placement_day = _get_placement_date(placement_post).date()
|
||||
placement = placement_post.placement
|
||||
if placement and placement.project:
|
||||
project_meta[placement_post.project_id] = placement.project
|
||||
if not placement:
|
||||
continue
|
||||
if placement.project:
|
||||
project_meta[placement.project_id] = placement.project
|
||||
|
||||
cost_value = placement_post.cost or 0.0
|
||||
cost_value = _get_cost(placement_post)
|
||||
cost_per_day[placement_day] += cost_value
|
||||
project_spending_map[placement_post.project_id] += cost_value
|
||||
project_spending_map[placement.project_id] += cost_value
|
||||
subs = subs_per_placement_current.get(placement_post.id, 0)
|
||||
|
||||
if placement:
|
||||
channel_id = placement.channel_id
|
||||
if channel_id not in channel_stats:
|
||||
channel_stats[channel_id] = ChannelAggregate(channel=placement.channel)
|
||||
channel_stats[channel_id].total_cost += cost_value
|
||||
channel_stats[channel_id].total_subs += subs
|
||||
channel_id = placement.channel_id
|
||||
if channel_id not in channel_stats:
|
||||
channel_stats[channel_id] = ChannelAggregate(channel=placement.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()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import datetime
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -9,6 +10,20 @@ if TYPE_CHECKING:
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_placement_date(placement_post: domain.PlacementPost) -> datetime.datetime:
|
||||
placement = placement_post.placement
|
||||
if placement and placement.placement_at:
|
||||
return placement.placement_at
|
||||
if placement_post.post and placement_post.post.created_at:
|
||||
return placement_post.post.created_at
|
||||
return placement_post.created_at
|
||||
|
||||
|
||||
def _get_cost(placement_post: domain.PlacementPost) -> float | None:
|
||||
placement = placement_post.placement
|
||||
return placement.cost_value if placement else None
|
||||
|
||||
|
||||
def _calculate_cpf(cost: float | None, subscriptions: int) -> float | None:
|
||||
if cost is None or subscriptions == 0:
|
||||
return None
|
||||
@@ -52,21 +67,36 @@ async def get_placements_analytics(
|
||||
placement_ids = [p.id for p in placements]
|
||||
subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_ids)
|
||||
|
||||
return [
|
||||
dto.PlacementAnalyticsOutput(
|
||||
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,
|
||||
creative_id=p.creative_id,
|
||||
creative_name=p.creative.name,
|
||||
placement_date=p.wanted_placement_date,
|
||||
cost=p.cost,
|
||||
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, 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),
|
||||
results: list[dto.PlacementAnalyticsOutput] = []
|
||||
for placement_post in placements:
|
||||
placement = placement_post.placement
|
||||
if not placement or not placement.project or not placement.channel or not placement.creative:
|
||||
log.warning('PlacementPost %s missing placement data', placement_post.id)
|
||||
continue
|
||||
if not placement.project.channel:
|
||||
log.warning('Placement %s missing project channel', placement.id)
|
||||
continue
|
||||
cost = _get_cost(placement_post)
|
||||
subs_count = subscriptions_counts.get(placement_post.id, 0)
|
||||
if placement_post.post and placement_post.post.id in views_map:
|
||||
views_count = views_map[placement_post.post.id][0]
|
||||
else:
|
||||
views_count = None
|
||||
results.append(
|
||||
dto.PlacementAnalyticsOutput(
|
||||
id=placement_post.id,
|
||||
project_id=placement.project_id,
|
||||
project_channel_title=placement.project.channel.title,
|
||||
placement_channel_id=placement.channel_id,
|
||||
placement_channel_title=placement.channel.title,
|
||||
creative_id=placement.creative_id,
|
||||
creative_name=placement.creative.name,
|
||||
placement_date=_get_placement_date(placement_post),
|
||||
cost=cost,
|
||||
subscriptions_count=subs_count,
|
||||
views_count=views_count,
|
||||
cpf=_calculate_cpf(cost, subs_count),
|
||||
cpm=_calculate_cpm(cost, views_count),
|
||||
)
|
||||
)
|
||||
for p in placements
|
||||
]
|
||||
return results
|
||||
|
||||
@@ -60,20 +60,29 @@ def _format_period_label(dt: datetime.datetime, grouping: dto.DateGrouping) -> s
|
||||
raise ValueError('Invalid date grouping')
|
||||
|
||||
|
||||
def _get_placement_date(placement_post: domain.PlacementPost) -> datetime.datetime:
|
||||
placement = placement_post.placement
|
||||
if placement and placement.placement_at:
|
||||
return placement.placement_at
|
||||
if placement_post.post and placement_post.post.created_at:
|
||||
return placement_post.post.created_at
|
||||
return placement_post.created_at
|
||||
|
||||
|
||||
def _get_grouping_date(placement_post: domain.PlacementPost, date_grouping: dto.DateGroupingType) -> datetime.datetime:
|
||||
match date_grouping:
|
||||
case dto.DateGroupingType.PLACEMENT_DATE:
|
||||
return placement_post.wanted_placement_date
|
||||
return _get_placement_date(placement_post)
|
||||
case dto.DateGroupingType.PURCHASE_DATE:
|
||||
if placement_post.placement:
|
||||
return placement_post.placement.created_at
|
||||
return placement_post.wanted_placement_date # Fallback
|
||||
return _get_placement_date(placement_post) # Fallback
|
||||
case dto.DateGroupingType.LINK_DATE:
|
||||
if placement_post.placement:
|
||||
return placement_post.placement.created_at
|
||||
return placement_post.wanted_placement_date # Fallback
|
||||
return _get_placement_date(placement_post) # Fallback
|
||||
case _:
|
||||
return placement_post.wanted_placement_date
|
||||
return _get_placement_date(placement_post)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -223,7 +232,8 @@ async def get_projects_analytics(
|
||||
pd.purchases_count += 1
|
||||
total_metrics.purchases_count += 1
|
||||
|
||||
cost = placement_post.cost or 0.0
|
||||
placement = placement_post.placement
|
||||
cost = placement.cost_value if placement and placement.cost_value is not None else 0.0
|
||||
pd.total_cost += cost
|
||||
total_metrics.total_cost += cost
|
||||
|
||||
@@ -241,7 +251,6 @@ async def get_projects_analytics(
|
||||
total_metrics.reach_volume += views_count
|
||||
|
||||
# Расчет скидок
|
||||
placement = placement_post.placement
|
||||
if placement and placement.cost_before_bargain and placement.cost_before_bargain > cost:
|
||||
discount = placement.cost_before_bargain - cost
|
||||
discount_percent = (
|
||||
|
||||
@@ -30,6 +30,20 @@ def _format_period(dt: 'datetime.datetime', grouping: dto.DateGrouping) -> str:
|
||||
raise ValueError('Invalid date grouping')
|
||||
|
||||
|
||||
def _get_placement_date(placement_post: domain.PlacementPost) -> datetime.datetime:
|
||||
placement = placement_post.placement
|
||||
if placement and placement.placement_at:
|
||||
return placement.placement_at
|
||||
if placement_post.post and placement_post.post.created_at:
|
||||
return placement_post.post.created_at
|
||||
return placement_post.created_at
|
||||
|
||||
|
||||
def _get_cost(placement_post: domain.PlacementPost) -> float | None:
|
||||
placement = placement_post.placement
|
||||
return placement.cost_value if placement else None
|
||||
|
||||
|
||||
async def get_spending_analytics(
|
||||
self: 'Usecase', input: dto.GetSpendingAnalyticsInput
|
||||
) -> dto.GetSpendingAnalyticsOutput:
|
||||
@@ -53,12 +67,14 @@ async def get_spending_analytics(
|
||||
allowed_project_ids=allowed_project_ids,
|
||||
)
|
||||
|
||||
filtered = [
|
||||
p
|
||||
for p in placements
|
||||
if (not input.date_from or p.wanted_placement_date >= input.date_from)
|
||||
and (not input.date_to or p.wanted_placement_date <= input.date_to)
|
||||
]
|
||||
filtered: list[tuple[domain.PlacementPost, datetime.datetime]] = []
|
||||
for placement_post in placements:
|
||||
placement_date = _get_placement_date(placement_post)
|
||||
if input.date_from and placement_date < input.date_from:
|
||||
continue
|
||||
if input.date_to and placement_date > input.date_to:
|
||||
continue
|
||||
filtered.append((placement_post, placement_date))
|
||||
|
||||
total_cost = 0.0
|
||||
total_subs = 0
|
||||
@@ -71,23 +87,24 @@ async def get_spending_analytics(
|
||||
views: int = 0
|
||||
|
||||
# Batch fetch views data for all posts
|
||||
post_ids = [p.post.id for p in filtered if p.post]
|
||||
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]
|
||||
placement_ids = [p.id for p, _ in filtered]
|
||||
subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_ids)
|
||||
|
||||
# Группировка по периодам
|
||||
period_data: dict[str, PeriodData] = defaultdict(PeriodData)
|
||||
|
||||
for p in filtered:
|
||||
period = _format_period(p.wanted_placement_date, input.grouping)
|
||||
for p, placement_date in filtered:
|
||||
period = _format_period(placement_date, input.grouping)
|
||||
pd = period_data[period]
|
||||
|
||||
if p.cost is not None:
|
||||
total_cost += p.cost
|
||||
pd.cost += p.cost
|
||||
cost = _get_cost(p)
|
||||
if cost is not None:
|
||||
total_cost += cost
|
||||
pd.cost += cost
|
||||
|
||||
subs_count = subscriptions_counts.get(p.id, 0)
|
||||
total_subs += subs_count
|
||||
|
||||
@@ -17,9 +17,12 @@ async def fetch_placement_post_cycle(self: 'Usecase', interval_seconds: int) ->
|
||||
placements = (
|
||||
await domain.Placement.filter(
|
||||
status__in=[
|
||||
domain.PlacementStatus.APPROVED,
|
||||
domain.PlacementStatus.PLANNED,
|
||||
domain.PlacementStatus.IN_PROGRESS,
|
||||
domain.PlacementStatus.NO_STATUS,
|
||||
domain.PlacementStatus.WRITE,
|
||||
domain.PlacementStatus.WAITING_RESPONSE,
|
||||
domain.PlacementStatus.TERMS_APPROVAL,
|
||||
domain.PlacementStatus.TO_PAY,
|
||||
domain.PlacementStatus.PAID,
|
||||
]
|
||||
)
|
||||
.prefetch_related('channel', 'project')
|
||||
@@ -36,6 +39,13 @@ async def fetch_placement_post_cycle(self: 'Usecase', interval_seconds: int) ->
|
||||
if placement.channel is None or placement.invite_link is None:
|
||||
log.warning('Placement %s missing channel or invite_link, skipping', placement.id)
|
||||
continue
|
||||
if placement.creative_id is None:
|
||||
log.warning('Placement %s missing creative_id, skipping', placement.id)
|
||||
continue
|
||||
existing_for_placement = await domain.PlacementPost.filter(placement_id=placement.id).first()
|
||||
if existing_for_placement:
|
||||
log.debug('Placement %s already has placement_post %s, skipping', placement.id, existing_for_placement.id)
|
||||
continue
|
||||
|
||||
# Ищем посты в канале, содержащие invite_link из placement
|
||||
posts = await domain.Post.filter(
|
||||
@@ -52,13 +62,7 @@ async def fetch_placement_post_cycle(self: 'Usecase', interval_seconds: int) ->
|
||||
|
||||
placement_post = domain.PlacementPost(
|
||||
placement_id=placement.id,
|
||||
project_id=placement.project_id,
|
||||
creative_id=placement.creative_id,
|
||||
post_id=post.id,
|
||||
wanted_placement_date=placement.placement_at or post.created_at,
|
||||
cost=placement.cost_value,
|
||||
comment=placement.comment,
|
||||
status=domain.PlacementPostStatus.ACTIVE,
|
||||
)
|
||||
|
||||
await self.database.create_placement_post(placement_post)
|
||||
|
||||
92
src/usecase/purchase/build_placement_creative.py
Normal file
92
src/usecase/purchase/build_placement_creative.py
Normal file
@@ -0,0 +1,92 @@
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain, dto
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_INVITE_LINK_TAG = re.compile(r'<a\s+class=["\']tg-link["\']>([^<]*)</a>', re.IGNORECASE)
|
||||
_INVITE_LINK_PLACEHOLDER = '{{invite_link}}'
|
||||
|
||||
|
||||
def _inject_invite_link(text: str, invite_link: str) -> str:
|
||||
if not text:
|
||||
return text
|
||||
|
||||
def _replace(match: re.Match) -> str:
|
||||
inner = match.group(1).strip()
|
||||
if inner:
|
||||
return f'<a href="{invite_link}">{inner}</a>'
|
||||
return f'<a href="{invite_link}">{invite_link}</a>'
|
||||
|
||||
return _INVITE_LINK_TAG.sub(_replace, text)
|
||||
|
||||
|
||||
def _build_buttons(buttons: list[dict], invite_link: str) -> list[dto.CreativeButton]:
|
||||
result: list[dto.CreativeButton] = []
|
||||
for raw in buttons:
|
||||
text = raw.get('text')
|
||||
url = raw.get('url')
|
||||
if not text or not url:
|
||||
continue
|
||||
if url == _INVITE_LINK_PLACEHOLDER:
|
||||
url = invite_link
|
||||
result.append(dto.CreativeButton(text=text, url=url))
|
||||
return result
|
||||
|
||||
|
||||
async def build_placement_creative(
|
||||
self: 'Usecase',
|
||||
placement_id: uuid.UUID,
|
||||
workspace_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> dto.CreativePreviewOutput:
|
||||
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:
|
||||
raise domain.ProjectNotFound(project_id)
|
||||
|
||||
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id)
|
||||
|
||||
placement = await self.database.get_placement(workspace_id, placement_id)
|
||||
if not placement or placement.project_id != project.id:
|
||||
raise domain.PlacementNotFound(placement_id)
|
||||
|
||||
if placement.creative_id is None:
|
||||
raise domain.CreativeNotFound()
|
||||
|
||||
creative = await self.database.get_creative(workspace_id, placement.creative_id)
|
||||
if not creative or creative.project_id != project.id:
|
||||
raise domain.CreativeNotFound(placement.creative_id)
|
||||
|
||||
if not placement.invite_link:
|
||||
if project.channel.telegram_id is None:
|
||||
raise domain.ChannelNotFound(project.channel.id)
|
||||
|
||||
requires_approval = placement.invite_link_type == domain.InviteLinkType.APPROVAL
|
||||
invite_link = await self.telegram_bot.create_chat_invite_link(project.channel.telegram_id, requires_approval)
|
||||
placement.invite_link = invite_link
|
||||
await self.database.update_placement(placement)
|
||||
|
||||
invite_link = placement.invite_link
|
||||
if not invite_link:
|
||||
raise domain.PlacementNotFound(placement.id)
|
||||
|
||||
preview = dto.CreativePreviewOutput(
|
||||
id=creative.id,
|
||||
name=creative.name,
|
||||
text=_inject_invite_link(creative.text, invite_link),
|
||||
media_type=creative.media_type,
|
||||
media_file_id=creative.media_file_id,
|
||||
buttons=_build_buttons(creative.buttons, invite_link),
|
||||
)
|
||||
return preview
|
||||
@@ -40,6 +40,7 @@ def _build_placement_output(placement: domain.Placement) -> dto.PlacementOutput:
|
||||
return dto.PlacementOutput(
|
||||
id=placement.id,
|
||||
status=placement.status,
|
||||
creative_id=placement.creative_id,
|
||||
comment=placement.comment,
|
||||
invite_link=placement.invite_link,
|
||||
invite_link_type=placement.invite_link_type,
|
||||
@@ -69,18 +70,13 @@ async def create_placements(
|
||||
|
||||
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id)
|
||||
|
||||
creative = await self.database.get_creative(workspace_id, input.creative_id)
|
||||
if not creative or creative.project_id != project.id:
|
||||
raise domain.CreativeNotFound(input.creative_id)
|
||||
|
||||
if project.channel.telegram_id is None:
|
||||
raise domain.ChannelNotFound(project.channel.id)
|
||||
|
||||
invite_link_type = project.purchase_invite_type_default
|
||||
requires_approval = invite_link_type == domain.InviteLinkType.APPROVAL
|
||||
|
||||
details = input.details
|
||||
placements: list[domain.Placement] = []
|
||||
creatives_by_id: dict[uuid.UUID, domain.Creative] = {}
|
||||
|
||||
for channel_input in input.channels:
|
||||
channel = await self.database.get_channel(username=channel_input.username)
|
||||
@@ -98,23 +94,26 @@ async def create_placements(
|
||||
await self.database.create_channel(channel)
|
||||
log.info('Created channel @%s with telegram_id=%s', channel.username, channel.telegram_id)
|
||||
|
||||
invite_link = await self.telegram_bot.create_chat_invite_link(project.channel.telegram_id, requires_approval)
|
||||
log.info(
|
||||
'Created invite link for placement channel_telegram_id=%s requires_approval=%s invite_link=%s',
|
||||
project.channel.telegram_id,
|
||||
requires_approval,
|
||||
invite_link,
|
||||
)
|
||||
|
||||
# Объединяем общие детали с деталями конкретного канала
|
||||
channel_details = channel_input.details
|
||||
creative_id = (
|
||||
channel_details.creative_id if channel_details and channel_details.creative_id else None
|
||||
) or (details.creative_id if details and details.creative_id else None) or input.creative_id
|
||||
if not creative_id:
|
||||
raise domain.CreativeNotFound()
|
||||
creative = creatives_by_id.get(creative_id)
|
||||
if not creative:
|
||||
creative = await self.database.get_creative(workspace_id, creative_id)
|
||||
if not creative or creative.project_id != project.id:
|
||||
raise domain.CreativeNotFound(creative_id)
|
||||
creatives_by_id[creative_id] = creative
|
||||
placement = domain.Placement(
|
||||
project_id=project.id,
|
||||
creative_id=creative.id,
|
||||
channel_id=channel.id,
|
||||
invite_link=invite_link,
|
||||
invite_link=None,
|
||||
invite_link_type=invite_link_type,
|
||||
status=channel_input.status or domain.PlacementStatus.PLANNED,
|
||||
status=channel_input.status or domain.PlacementStatus.NO_STATUS,
|
||||
comment=channel_input.comment,
|
||||
# Приоритет: channel details > общие details
|
||||
placement_at=(channel_details.placement_at if channel_details else None)
|
||||
@@ -135,10 +134,10 @@ async def create_placements(
|
||||
placements.append(placement)
|
||||
|
||||
log.info(
|
||||
'Created %s placements for project %s (creative %s)',
|
||||
'Created %s placements for project %s (creatives %s)',
|
||||
len(placements),
|
||||
project.id,
|
||||
creative.id,
|
||||
list(creatives_by_id.keys()),
|
||||
)
|
||||
|
||||
placement_outputs = []
|
||||
@@ -147,7 +146,7 @@ async def create_placements(
|
||||
placement_outputs.append(
|
||||
dto.PlacementWithPostsOutput(
|
||||
**placement_output.model_dump(),
|
||||
placement_posts=[],
|
||||
placement_post=None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
28
src/usecase/purchase/delete_placement.py
Normal file
28
src/usecase/purchase/delete_placement.py
Normal file
@@ -0,0 +1,28 @@
|
||||
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
|
||||
)
|
||||
|
||||
project = await self.database.get_project(input.workspace_id, project_id=input.project_id)
|
||||
if not project:
|
||||
raise domain.ProjectNotFound(input.project_id)
|
||||
|
||||
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id)
|
||||
|
||||
placement = await self.database.get_placement(input.workspace_id, input.placement_id)
|
||||
if not placement or placement.project_id != project.id:
|
||||
log.warning('User %s attempted to delete unavailable placement %s', input.user_id, input.placement_id)
|
||||
raise domain.PlacementNotFound(input.placement_id)
|
||||
|
||||
await self.database.delete_placement(placement.id)
|
||||
@@ -7,39 +7,40 @@ from src.usecase.purchase.create_placements import _build_placement_output
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_post_output(post: domain.Post) -> dto.PostOutput:
|
||||
channel = post.channel
|
||||
if not channel:
|
||||
raise ValueError(f'Post {post.id} has no channel')
|
||||
|
||||
return dto.PostOutput(
|
||||
id=post.id,
|
||||
message_id=post.message_id,
|
||||
text=post.text,
|
||||
url=post.url,
|
||||
deleted_from_channel_at=post.deleted_from_channel_at,
|
||||
created_at=post.created_at,
|
||||
updated_at=post.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _build_placement_post_output(
|
||||
placement_post: domain.PlacementPost, subscriptions_count: int
|
||||
placement_post: domain.PlacementPost, subscriptions_count: int, views_count: int | None
|
||||
) -> dto.PlacementPostOutput | None:
|
||||
placement = placement_post.placement
|
||||
if not placement:
|
||||
log.warning('PlacementPost %s missing placement data', placement_post.id)
|
||||
if not placement_post.post:
|
||||
log.warning('PlacementPost %s missing post', placement_post.id)
|
||||
return None
|
||||
|
||||
if not placement.channel or not placement.project or not placement.creative:
|
||||
log.warning('PlacementPost %s placement missing related data', placement_post.id)
|
||||
try:
|
||||
post_output = _build_post_output(placement_post.post)
|
||||
except ValueError:
|
||||
log.warning('PlacementPost %s post missing channel data', placement_post.id)
|
||||
return None
|
||||
|
||||
ad_post_url = placement_post.post.url if placement_post.post else None
|
||||
|
||||
return dto.PlacementPostOutput(
|
||||
id=placement_post.id,
|
||||
project_id=placement.project_id,
|
||||
project_channel_title=placement.project.channel.title if placement.project.channel else None,
|
||||
placement_channel_id=placement.channel_id,
|
||||
placement_channel_title=placement.channel.title,
|
||||
creative_id=placement.creative_id,
|
||||
creative_name=placement.creative.name,
|
||||
placement_date=placement.placement_at or placement_post.created_at,
|
||||
cost=placement.cost_value,
|
||||
comment=placement.comment,
|
||||
ad_post_url=ad_post_url,
|
||||
invite_link_type=placement.invite_link_type,
|
||||
invite_link=placement.invite_link,
|
||||
status=placement_post.status,
|
||||
subscriptions_count=subscriptions_count,
|
||||
placement_id=placement.id,
|
||||
placement=_build_placement_output(placement),
|
||||
views_count=views_count,
|
||||
created_at=placement_post.created_at,
|
||||
post=post_output,
|
||||
)
|
||||
|
||||
|
||||
@@ -75,17 +76,27 @@ async def get_placement_user(self: 'Usecase', input: dto.GetPlacementInput) -> d
|
||||
)
|
||||
placement_post_ids = [post.id for post in placement_posts]
|
||||
subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_post_ids)
|
||||
post_ids = [placement_post.post.id for placement_post in placement_posts if placement_post.post]
|
||||
views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {}
|
||||
|
||||
placement_post_outputs = []
|
||||
for placement_post in placement_posts:
|
||||
placement_post_output = _build_placement_post_output(
|
||||
placement_post, subscriptions_counts.get(placement_post.id, 0)
|
||||
)
|
||||
if placement_post_output is not None:
|
||||
placement_post_outputs.append(placement_post_output)
|
||||
placement_post_output = None
|
||||
if placement_posts:
|
||||
if len(placement_posts) > 1:
|
||||
log.warning('Placement %s has %s placement_posts, returning latest', placement.id, len(placement_posts))
|
||||
for placement_post in placement_posts:
|
||||
views_count = None
|
||||
if placement_post.post:
|
||||
views_count = views_map.get(placement_post.post.id, (None,))[0]
|
||||
placement_post_output = _build_placement_post_output(
|
||||
placement_post,
|
||||
subscriptions_counts.get(placement_post.id, 0),
|
||||
views_count,
|
||||
)
|
||||
if placement_post_output is not None:
|
||||
break
|
||||
|
||||
placement_output = _build_placement_output(placement)
|
||||
return dto.PlacementWithPostsOutput(
|
||||
**placement_output.model_dump(),
|
||||
placement_posts=placement_post_outputs,
|
||||
placement_post=placement_post_output,
|
||||
)
|
||||
|
||||
@@ -36,11 +36,18 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.
|
||||
)
|
||||
placement_post_ids = [post.id for post in placement_posts]
|
||||
subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_post_ids)
|
||||
post_ids = [placement_post.post.id for placement_post in placement_posts if placement_post.post]
|
||||
views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {}
|
||||
|
||||
placement_posts_by_placement_id: dict[uuid.UUID, list[dto.PlacementPostOutput]] = {}
|
||||
for placement_post in placement_posts:
|
||||
views_count = None
|
||||
if placement_post.post:
|
||||
views_count = views_map.get(placement_post.post.id, (None,))[0]
|
||||
placement_post_output = _build_placement_post_output(
|
||||
placement_post, subscriptions_counts.get(placement_post.id, 0)
|
||||
placement_post,
|
||||
subscriptions_counts.get(placement_post.id, 0),
|
||||
views_count,
|
||||
)
|
||||
if placement_post_output is None:
|
||||
continue
|
||||
@@ -49,10 +56,18 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.
|
||||
placement_outputs = []
|
||||
for placement in placements:
|
||||
placement_output = _build_placement_output(placement)
|
||||
placement_post_output = None
|
||||
placement_posts_for_placement = placement_posts_by_placement_id.get(placement.id, [])
|
||||
if placement_posts_for_placement:
|
||||
if len(placement_posts_for_placement) > 1:
|
||||
log.warning(
|
||||
'Placement %s has %s placement_posts, returning latest', placement.id, len(placement_posts_for_placement)
|
||||
)
|
||||
placement_post_output = placement_posts_for_placement[0]
|
||||
placement_outputs.append(
|
||||
dto.PlacementWithPostsOutput(
|
||||
**placement_output.model_dump(),
|
||||
placement_posts=placement_posts_by_placement_id.get(placement.id, []),
|
||||
placement_post=placement_post_output,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
80
src/usecase/purchase/update_placement.py
Normal file
80
src/usecase/purchase/update_placement.py
Normal file
@@ -0,0 +1,80 @@
|
||||
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,
|
||||
workspace_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> dto.PlacementWithPostsOutput:
|
||||
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:
|
||||
raise domain.ProjectNotFound(project_id)
|
||||
|
||||
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id)
|
||||
|
||||
placement = await self.database.get_placement(workspace_id, placement_id)
|
||||
if not placement or placement.project_id != project.id:
|
||||
raise domain.PlacementNotFound(placement_id)
|
||||
|
||||
fields_set = input.model_fields_set
|
||||
|
||||
if 'creative_id' in fields_set:
|
||||
if input.creative_id is None:
|
||||
placement_posts_count = await self.database.count_placement_posts_by_placement(placement.id)
|
||||
if placement_posts_count > 0:
|
||||
log.warning('Placement %s has placement_posts and cannot remove creative', placement.id)
|
||||
raise domain.PlacementHasPosts(placement.id)
|
||||
placement.creative_id = None
|
||||
else:
|
||||
creative = await self.database.get_creative(workspace_id, input.creative_id)
|
||||
if not creative or creative.project_id != project.id:
|
||||
raise domain.CreativeNotFound(input.creative_id)
|
||||
placement.creative_id = creative.id
|
||||
|
||||
if 'status' in fields_set and input.status is not None:
|
||||
placement.status = input.status
|
||||
if 'comment' in fields_set:
|
||||
placement.comment = input.comment
|
||||
if 'placement_at' in fields_set:
|
||||
placement.placement_at = input.placement_at
|
||||
if 'payment_at' in fields_set:
|
||||
placement.payment_at = input.payment_at
|
||||
if 'cost' in fields_set:
|
||||
if input.cost is None:
|
||||
placement.cost_type = None
|
||||
placement.cost_value = None
|
||||
else:
|
||||
placement.cost_type = input.cost.type
|
||||
placement.cost_value = input.cost.value
|
||||
if 'cost_before_bargain' in fields_set:
|
||||
placement.cost_before_bargain = input.cost_before_bargain
|
||||
if 'placement_type' in fields_set:
|
||||
placement.placement_type = input.placement_type
|
||||
if 'format' in fields_set:
|
||||
placement.format = input.format
|
||||
|
||||
await self.database.update_placement(placement)
|
||||
|
||||
placement_input = dto.GetPlacementInput(
|
||||
user_id=user_id,
|
||||
workspace_id=workspace_id,
|
||||
project_id=project_id,
|
||||
placement_id=placement.id,
|
||||
)
|
||||
return await self.get_placement_user(input=placement_input)
|
||||
Reference in New Issue
Block a user