refactor
This commit is contained in:
@@ -311,105 +311,6 @@ class Postgres(DatabaseBase, Database):
|
||||
project = await domain.Project.get_or_none(channel_id=channel_id, workspace_id=workspace_id)
|
||||
return project is not None
|
||||
|
||||
async def get_active_purchase(self, project_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Purchase | None:
|
||||
return await domain.Purchase.get_or_none(
|
||||
project_id=project_id, creative_id=creative_id, status=domain.PurchaseStatus.ACTIVE
|
||||
)
|
||||
|
||||
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, 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(project__workspace_id=workspace_id, project_id=project_id)
|
||||
.prefetch_related('channels', 'channels__channel')
|
||||
.all()
|
||||
)
|
||||
|
||||
async def create_purchase(self, purchase: domain.Purchase) -> None:
|
||||
await purchase.save()
|
||||
|
||||
async def get_purchase_channels(self, purchase_id: uuid.UUID) -> list[domain.PurchaseChannel]:
|
||||
return await domain.PurchaseChannel.filter(purchase_id=purchase_id).prefetch_related('channel').all()
|
||||
|
||||
async def get_purchase_channel(
|
||||
self, purchase_channel_id: uuid.UUID, purchase_id: uuid.UUID
|
||||
) -> domain.PurchaseChannel | None:
|
||||
return (
|
||||
await domain.PurchaseChannel.filter(id=purchase_channel_id, purchase_id=purchase_id)
|
||||
.prefetch_related('channel')
|
||||
.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(
|
||||
purchase__project_id=project_id, purchase__status=domain.PurchaseStatus.ACTIVE
|
||||
)
|
||||
.prefetch_related('channel')
|
||||
.all()
|
||||
)
|
||||
|
||||
async def add_channel_to_purchase(
|
||||
self,
|
||||
purchase_id: uuid.UUID,
|
||||
channel_id: uuid.UUID,
|
||||
invite_link: str,
|
||||
invite_link_type: domain.purchase.InviteLinkType,
|
||||
*,
|
||||
status: domain.PurchaseChannelStatus | None = None,
|
||||
comment: str | None = None,
|
||||
placement_at: datetime.datetime | None = None,
|
||||
cost_type: domain.purchase.CostType | None = None,
|
||||
cost_value: float | None = None,
|
||||
cost_before_bargain: float | None = None,
|
||||
format: str | None = None,
|
||||
) -> domain.PurchaseChannel:
|
||||
defaults: dict[str, object | None] = {
|
||||
'invite_link': invite_link,
|
||||
'invite_link_type': invite_link_type,
|
||||
}
|
||||
if status is not None:
|
||||
defaults['status'] = status
|
||||
if comment is not None:
|
||||
defaults['comment'] = comment
|
||||
if placement_at is not None:
|
||||
defaults['placement_at'] = placement_at
|
||||
if cost_type is not None:
|
||||
defaults['cost_type'] = cost_type
|
||||
if cost_value is not None:
|
||||
defaults['cost_value'] = cost_value
|
||||
if cost_before_bargain is not None:
|
||||
defaults['cost_before_bargain'] = cost_before_bargain
|
||||
if format is not None:
|
||||
defaults['format'] = format
|
||||
|
||||
purchase_channel, created = await domain.PurchaseChannel.get_or_create(
|
||||
purchase_id=purchase_id,
|
||||
channel_id=channel_id,
|
||||
defaults=defaults,
|
||||
)
|
||||
|
||||
if not created and defaults:
|
||||
for field, value in defaults.items():
|
||||
setattr(purchase_channel, field, value)
|
||||
await purchase_channel.save()
|
||||
|
||||
return purchase_channel
|
||||
|
||||
async def update_purchase_channel(self, purchase_channel: domain.PurchaseChannel) -> None:
|
||||
await purchase_channel.save()
|
||||
|
||||
async def remove_channel_from_purchase(self, purchase_id: uuid.UUID, channel_id: uuid.UUID) -> None:
|
||||
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, project__workspace_id=workspace_id
|
||||
@@ -442,26 +343,51 @@ class Postgres(DatabaseBase, Database):
|
||||
async def delete_creative(self, creative_id: uuid.UUID) -> None:
|
||||
await domain.Creative.filter(id=creative_id).delete()
|
||||
|
||||
# Placement methods (user-managed, запланированные размещения)
|
||||
async def create_placement(self, placement: domain.Placement) -> None:
|
||||
await placement.save()
|
||||
|
||||
async def create_placements(self, placements: list[domain.Placement]) -> None:
|
||||
if placements:
|
||||
await domain.Placement.bulk_create(placements)
|
||||
|
||||
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, project__workspace_id=workspace_id
|
||||
).prefetch_related('project', 'project__channel', 'channel', 'creative')
|
||||
|
||||
async def get_project_placements(
|
||||
self, workspace_id: uuid.UUID, project_id: uuid.UUID, include_archived: bool = False
|
||||
) -> list[domain.Placement]:
|
||||
query = domain.Placement.filter(project__workspace_id=workspace_id, project_id=project_id)
|
||||
|
||||
if not include_archived:
|
||||
query = query.filter(status__in=[domain.PlacementStatus.APPROVED, domain.PlacementStatus.IN_PROGRESS])
|
||||
|
||||
return (
|
||||
await query.prefetch_related('project', 'project__channel', 'channel', 'creative')
|
||||
.order_by('-created_at')
|
||||
.all()
|
||||
)
|
||||
|
||||
async def update_placement_user(self, placement: domain.Placement) -> None:
|
||||
await placement.save()
|
||||
|
||||
# Publication methods (system-managed, фактически размещенные посты)
|
||||
async def get_publication(self, workspace_id: uuid.UUID, publication_id: uuid.UUID) -> domain.Publication | None:
|
||||
return await domain.Publication.get_or_none(
|
||||
id=publication_id, placement__project__workspace_id=workspace_id
|
||||
).prefetch_related(
|
||||
'project',
|
||||
'project__channel',
|
||||
'purchase_channel',
|
||||
'purchase_channel__channel',
|
||||
'creative',
|
||||
'placement',
|
||||
'placement__project',
|
||||
'placement__project__channel',
|
||||
'placement__channel',
|
||||
'placement__creative',
|
||||
'post',
|
||||
'post__channel',
|
||||
)
|
||||
|
||||
async def create_placement(self, placement: domain.Placement) -> None:
|
||||
await placement.save()
|
||||
|
||||
async def update_placement(self, placement: domain.Placement) -> None:
|
||||
await placement.save()
|
||||
|
||||
async def get_workspace_placements(
|
||||
async def get_workspace_publications(
|
||||
self,
|
||||
workspace_id: uuid.UUID,
|
||||
project_id: uuid.UUID | None = None,
|
||||
@@ -471,75 +397,85 @@ class Postgres(DatabaseBase, Database):
|
||||
allowed_project_ids: set[uuid.UUID] | None = None,
|
||||
date_from: datetime.datetime | None = None,
|
||||
date_to: datetime.datetime | None = None,
|
||||
) -> list[domain.Placement]:
|
||||
query = domain.Placement.filter(project__workspace_id=workspace_id)
|
||||
) -> list[domain.Publication]:
|
||||
query = domain.Publication.filter(placement__project__workspace_id=workspace_id)
|
||||
|
||||
if project_id:
|
||||
query = query.filter(project_id=project_id)
|
||||
query = query.filter(placement__project_id=project_id)
|
||||
elif allowed_project_ids is not None:
|
||||
if not allowed_project_ids:
|
||||
return []
|
||||
query = query.filter(project_id__in=list(allowed_project_ids))
|
||||
query = query.filter(placement__project_id__in=list(allowed_project_ids))
|
||||
if placement_channel_id:
|
||||
query = query.filter(purchase_channel__channel_id=placement_channel_id)
|
||||
query = query.filter(placement__channel_id=placement_channel_id)
|
||||
if creative_id:
|
||||
query = query.filter(creative_id=creative_id)
|
||||
query = query.filter(placement__creative_id=creative_id)
|
||||
|
||||
if date_from:
|
||||
query = query.filter(wanted_placement_date__gte=date_from)
|
||||
query = query.filter(created_at__gte=date_from)
|
||||
if date_to:
|
||||
query = query.filter(wanted_placement_date__lte=date_to)
|
||||
query = query.filter(created_at__lte=date_to)
|
||||
|
||||
if not include_archived:
|
||||
query = query.filter(status=domain.PlacementStatus.ACTIVE)
|
||||
query = query.filter(status=domain.PublicationStatus.ACTIVE)
|
||||
|
||||
return (
|
||||
await query.prefetch_related(
|
||||
'project',
|
||||
'project__channel',
|
||||
'purchase_channel',
|
||||
'purchase_channel__channel',
|
||||
'purchase_channel__purchase',
|
||||
'creative',
|
||||
'placement',
|
||||
'placement__project',
|
||||
'placement__project__channel',
|
||||
'placement__channel',
|
||||
'placement__creative',
|
||||
'post',
|
||||
'post__channel',
|
||||
)
|
||||
.order_by('-wanted_placement_date')
|
||||
.order_by('-created_at')
|
||||
.all()
|
||||
)
|
||||
|
||||
async def delete_placement(self, placement_id: uuid.UUID) -> None:
|
||||
await domain.Placement.filter(id=placement_id).delete()
|
||||
async def create_publication(self, publication: domain.Publication) -> None:
|
||||
await publication.save()
|
||||
|
||||
async def get_placement_by_invite_link(self, invite_link: str) -> domain.Placement | None:
|
||||
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 update_publication(self, publication: domain.Publication) -> None:
|
||||
await publication.save()
|
||||
|
||||
async def delete_publication(self, publication_id: uuid.UUID) -> None:
|
||||
await domain.Publication.filter(id=publication_id).delete()
|
||||
|
||||
async def get_publication_by_invite_link(self, invite_link: str) -> domain.Publication | None:
|
||||
return await domain.Publication.get_or_none(placement__invite_link=invite_link).prefetch_related(
|
||||
'placement',
|
||||
'placement__project',
|
||||
'placement__project__channel',
|
||||
'placement__channel',
|
||||
'placement__creative',
|
||||
)
|
||||
|
||||
# Subscription methods
|
||||
async def create_subscription(self, subscription: domain.Subscription) -> None:
|
||||
await subscription.save()
|
||||
|
||||
async def get_subscription_by_subscriber_and_placement(
|
||||
self, telegram_user_id: uuid.UUID, placement_id: uuid.UUID
|
||||
async def get_subscription_by_subscriber_and_publication(
|
||||
self, telegram_user_id: uuid.UUID, publication_id: uuid.UUID
|
||||
) -> domain.Subscription | None:
|
||||
return await domain.Subscription.get_or_none(
|
||||
telegram_user_id=telegram_user_id, placement_id=placement_id
|
||||
telegram_user_id=telegram_user_id, publication_id=publication_id
|
||||
)
|
||||
|
||||
async def update_subscription(self, subscription: domain.Subscription) -> None:
|
||||
await subscription.save()
|
||||
|
||||
async def get_subscriptions_for_placements(
|
||||
async def get_subscriptions_for_publications(
|
||||
self,
|
||||
placement_ids: list[uuid.UUID],
|
||||
publication_ids: list[uuid.UUID],
|
||||
*,
|
||||
date_from: datetime.datetime | None = None,
|
||||
date_to: datetime.datetime | None = None,
|
||||
) -> list[domain.Subscription]:
|
||||
if not placement_ids:
|
||||
if not publication_ids:
|
||||
return []
|
||||
|
||||
query = domain.Subscription.filter(placement_id__in=placement_ids)
|
||||
query = domain.Subscription.filter(publication_id__in=publication_ids)
|
||||
|
||||
if date_from:
|
||||
query = query.filter(created_at__gte=date_from)
|
||||
@@ -554,10 +490,10 @@ class Postgres(DatabaseBase, Database):
|
||||
return (
|
||||
await domain.Subscription.filter(
|
||||
telegram_user_id=telegram_user_id,
|
||||
placement__project_id=project_id,
|
||||
publication__placement__project_id=project_id,
|
||||
status=domain.SubscriptionStatus.ACTIVE,
|
||||
)
|
||||
.prefetch_related('placement', 'telegram_user')
|
||||
.prefetch_related('publication', 'telegram_user')
|
||||
.all()
|
||||
)
|
||||
|
||||
@@ -567,10 +503,10 @@ class Postgres(DatabaseBase, Database):
|
||||
return (
|
||||
await domain.Subscription.filter(
|
||||
telegram_user_id=telegram_user_id,
|
||||
placement__project_id=project_id,
|
||||
publication__placement__project_id=project_id,
|
||||
status=domain.SubscriptionStatus.ACTIVE,
|
||||
)
|
||||
.prefetch_related('placement', 'telegram_user')
|
||||
.prefetch_related('publication', 'telegram_user')
|
||||
.first()
|
||||
)
|
||||
|
||||
@@ -625,43 +561,43 @@ class Postgres(DatabaseBase, Database):
|
||||
return results
|
||||
|
||||
# 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_publications_by_creative(self, creative_id: uuid.UUID) -> int:
|
||||
return await domain.Publication.filter(placement__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_subscriptions_by_publication(self, publication_id: uuid.UUID) -> int:
|
||||
return await domain.Subscription.filter(publication_id=publication_id).count()
|
||||
|
||||
async def count_placements_by_creative_batch(self, creative_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]:
|
||||
async def count_publications_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')
|
||||
await domain.Publication.filter(placement__creative_id__in=creative_ids)
|
||||
.group_by('placement__creative_id')
|
||||
.annotate(count=Count('id'))
|
||||
.values('creative_id', 'count')
|
||||
.values('placement__creative_id', 'count')
|
||||
)
|
||||
|
||||
counts = {row['creative_id']: row['count'] for row in results}
|
||||
counts = {row['placement__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:
|
||||
async def count_subscriptions_by_publication_batch(self, publication_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]:
|
||||
if not publication_ids:
|
||||
return {}
|
||||
|
||||
from tortoise.functions import Count
|
||||
|
||||
results = (
|
||||
await domain.Subscription.filter(placement_id__in=placement_ids)
|
||||
.group_by('placement_id')
|
||||
await domain.Subscription.filter(publication_id__in=publication_ids)
|
||||
.group_by('publication_id')
|
||||
.annotate(count=Count('id'))
|
||||
.values('placement_id', 'count')
|
||||
.values('publication_id', 'count')
|
||||
)
|
||||
|
||||
counts = {row['placement_id']: row['count'] for row in results}
|
||||
return {pid: counts.get(pid, 0) for pid in placement_ids}
|
||||
counts = {row['publication_id']: row['count'] for row in results}
|
||||
return {pid: counts.get(pid, 0) for pid in publication_ids}
|
||||
|
||||
async def has_placements_for_creative(self, creative_id: uuid.UUID) -> bool:
|
||||
return await domain.Placement.filter(creative_id=creative_id).exists()
|
||||
async def has_publications_for_creative(self, creative_id: uuid.UUID) -> bool:
|
||||
return await domain.Publication.filter(placement__creative_id=creative_id).exists()
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import aioboto3
|
||||
from botocore.config import Config
|
||||
import aioboto3 # type: ignore[import-untyped]
|
||||
from botocore.config import Config # type: ignore[import-untyped]
|
||||
from pydantic import BaseModel
|
||||
from types_aiobotocore_s3.client import S3Client
|
||||
|
||||
@@ -89,7 +88,7 @@ class S3(S3Storage):
|
||||
config=self.boto_config,
|
||||
) as client:
|
||||
response = await client.get_object(Bucket=self.config.BUCKET_NAME, Key=key)
|
||||
data = await response['Body'].read()
|
||||
data: bytes = await response['Body'].read()
|
||||
log.info(f'Downloaded file from S3: {key}')
|
||||
return data
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@ from src.controller.http_v1.auth import auth_router
|
||||
from src.controller.http_v1.channels import channels_router
|
||||
from src.controller.http_v1.creatives import creatives_router
|
||||
from src.controller.http_v1.internal import internal_router
|
||||
from src.controller.http_v1.placements import placements_router
|
||||
from src.controller.http_v1.placements import publications_router
|
||||
from src.controller.http_v1.projects import projects_router
|
||||
from src.controller.http_v1.purchases import purchase_router
|
||||
from src.controller.http_v1.purchases import placements_user_router
|
||||
from src.controller.http_v1.views import views_router
|
||||
from src.controller.http_v1.workspace_invites import workspace_invites_router, workspace_invites_global_router
|
||||
from src.controller.http_v1.workspace_invites import workspace_invites_global_router, workspace_invites_router
|
||||
from src.controller.http_v1.workspace_members import workspace_members_router
|
||||
from src.controller.http_v1.workspaces import workspaces_router
|
||||
|
||||
@@ -21,9 +21,9 @@ api_v1_router.include_router(auth_router)
|
||||
api_v1_router.include_router(internal_router)
|
||||
api_v1_router.include_router(channels_router)
|
||||
api_v1_router.include_router(projects_router)
|
||||
api_v1_router.include_router(purchase_router)
|
||||
api_v1_router.include_router(placements_user_router) # User-managed placements
|
||||
api_v1_router.include_router(creatives_router)
|
||||
api_v1_router.include_router(placements_router)
|
||||
api_v1_router.include_router(publications_router) # System-managed publications
|
||||
api_v1_router.include_router(views_router)
|
||||
api_v1_router.include_router(analytics_router)
|
||||
api_v1_router.include_router(workspaces_router)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi.routing import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -68,7 +70,7 @@ async def attach_channel_to_workspace(input: AttachChannelToWorkspaceRequest) ->
|
||||
|
||||
|
||||
@internal_router.post('/telegram/updates')
|
||||
async def handle_telegram_update(update: dict) -> None:
|
||||
async def handle_telegram_update(update: dict[str, Any]) -> None:
|
||||
"""Обрабатывает системные события от Golang бота (echotron формат)"""
|
||||
import json
|
||||
import logging
|
||||
@@ -105,7 +107,7 @@ async def handle_telegram_update(update: dict) -> None:
|
||||
log.warning('Unknown update type from Golang bot: %s', list(update.keys()))
|
||||
|
||||
|
||||
async def _handle_chat_join_request(event: dict) -> None:
|
||||
async def _handle_chat_join_request(event: dict[str, Any]) -> None:
|
||||
"""Обработка запроса на вступление в канал через invite link"""
|
||||
import logging
|
||||
|
||||
@@ -138,7 +140,7 @@ async def _handle_chat_join_request(event: dict) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def _handle_chat_member_updated(event: dict) -> None:
|
||||
async def _handle_chat_member_updated(event: dict[str, Any]) -> None:
|
||||
"""Обработка изменения статуса участника канала (вступление/выход)"""
|
||||
import logging
|
||||
|
||||
@@ -192,7 +194,7 @@ async def _handle_chat_member_updated(event: dict) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def _handle_my_chat_member(event: dict) -> None:
|
||||
async def _handle_my_chat_member(event: dict[str, Any]) -> None:
|
||||
"""Обработка добавления/удаления бота из канала"""
|
||||
import logging
|
||||
|
||||
|
||||
@@ -3,24 +3,25 @@ from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi.routing import APIRouter
|
||||
from fastapi_pagination import Page, paginate
|
||||
|
||||
from src import deps, dto
|
||||
from src.adapter.jwt import JWTPayload
|
||||
|
||||
placements_router = APIRouter(prefix='/workspaces/{workspace_id}/placements', tags=['placements'])
|
||||
# Publications router - System-managed actual published posts
|
||||
publications_router = APIRouter(prefix='/workspaces/{workspace_id}/publications', tags=['publications'])
|
||||
|
||||
|
||||
@placements_router.get('')
|
||||
async def list_placements(
|
||||
@publications_router.get('')
|
||||
async def list_publications(
|
||||
workspace_id: uuid.UUID,
|
||||
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
||||
project_id: uuid.UUID | None = None,
|
||||
placement_channel_id: uuid.UUID | None = None,
|
||||
creative_id: uuid.UUID | None = None,
|
||||
include_archived: bool = False,
|
||||
) -> Page[dto.PlacementOutput]:
|
||||
input = dto.GetPlacementsInput(
|
||||
) -> dto.GetPublicationsOutput:
|
||||
"""List all publications (system-managed actual posts) in workspace"""
|
||||
input = dto.GetPublicationsInput(
|
||||
user_id=current_user.user_id,
|
||||
workspace_id=workspace_id,
|
||||
project_id=project_id,
|
||||
@@ -29,23 +30,24 @@ async def list_placements(
|
||||
include_archived=include_archived,
|
||||
)
|
||||
|
||||
result = await deps.get_usecase().get_placements(input=input)
|
||||
return paginate(result) # type: ignore[no-any-return]
|
||||
result = await deps.get_usecase().get_publications(input=input)
|
||||
return result
|
||||
|
||||
|
||||
@placements_router.get('/{placement_id}')
|
||||
async def get_placement(
|
||||
placement_id: uuid.UUID,
|
||||
@publications_router.get('/{publication_id}')
|
||||
async def get_publication(
|
||||
publication_id: uuid.UUID,
|
||||
workspace_id: uuid.UUID,
|
||||
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
||||
) -> dto.PlacementOutput:
|
||||
input = dto.GetPlacementInput(
|
||||
placement_id=placement_id,
|
||||
) -> dto.PublicationOutput:
|
||||
"""Get single publication by ID"""
|
||||
input = dto.GetPublicationInput(
|
||||
publication_id=publication_id,
|
||||
user_id=current_user.user_id,
|
||||
workspace_id=workspace_id,
|
||||
)
|
||||
|
||||
return await deps.get_usecase().get_placement(input=input)
|
||||
return await deps.get_usecase().get_publication(input=input)
|
||||
|
||||
|
||||
# Placement creation/updates are handled by worker; API exposes only read endpoints.
|
||||
# Publication creation/updates are handled by worker; API exposes only read endpoints.
|
||||
|
||||
@@ -3,25 +3,26 @@ from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi.routing import APIRouter
|
||||
from fastapi_pagination import Page, paginate
|
||||
|
||||
from src import deps, dto
|
||||
from src.adapter.jwt import JWTPayload
|
||||
|
||||
purchase_router = APIRouter(
|
||||
prefix='/workspaces/{workspace_id}/projects/{project_id}/purchase',
|
||||
tags=['purchase'],
|
||||
# Placement router - User-managed planned placements
|
||||
placements_user_router = APIRouter(
|
||||
prefix='/workspaces/{workspace_id}/projects/{project_id}/placements',
|
||||
tags=['placements'],
|
||||
)
|
||||
|
||||
|
||||
@purchase_router.post('')
|
||||
async def create_purchase(
|
||||
request: dto.CreatePurchaseInput,
|
||||
@placements_user_router.post('')
|
||||
async def create_placements(
|
||||
request: dto.CreatePlacementsInput,
|
||||
workspace_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
||||
) -> dto.PurchaseOutput:
|
||||
return await deps.get_usecase().create_purchase(
|
||||
) -> dto.GetPlacementsOutput:
|
||||
"""Create multiple placements for different channels (bulk creation)"""
|
||||
return await deps.get_usecase().create_placements(
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
user_id=current_user.user_id,
|
||||
@@ -29,32 +30,34 @@ async def create_purchase(
|
||||
)
|
||||
|
||||
|
||||
@purchase_router.get('')
|
||||
async def get_purchases(
|
||||
@placements_user_router.get('')
|
||||
async def get_placements(
|
||||
workspace_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
||||
) -> Page[dto.PurchaseOutput]:
|
||||
input = dto.GetPurchasesInput(
|
||||
) -> dto.GetPlacementsOutput:
|
||||
"""Get all placements for a project"""
|
||||
input = dto.GetPlacementsInput(
|
||||
user_id=current_user.user_id,
|
||||
workspace_id=workspace_id,
|
||||
project_id=project_id,
|
||||
)
|
||||
result = await deps.get_usecase().get_purchases(input=input)
|
||||
return paginate(result) # type: ignore[no-any-return]
|
||||
result = await deps.get_usecase().get_placements(input=input)
|
||||
return result
|
||||
|
||||
|
||||
@purchase_router.get('/{purchase_id}')
|
||||
async def get_purchase(
|
||||
purchase_id: uuid.UUID,
|
||||
@placements_user_router.get('/{placement_id}')
|
||||
async def get_placement(
|
||||
placement_id: uuid.UUID,
|
||||
workspace_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
||||
) -> dto.PurchaseOutput:
|
||||
input = dto.GetPurchaseInput(
|
||||
) -> dto.PlacementOutput:
|
||||
"""Get single placement by ID"""
|
||||
input = dto.GetPlacementInput(
|
||||
user_id=current_user.user_id,
|
||||
workspace_id=workspace_id,
|
||||
project_id=project_id,
|
||||
purchase_id=purchase_id,
|
||||
placement_id=placement_id,
|
||||
)
|
||||
return await deps.get_usecase().get_purchase(input=input)
|
||||
return await deps.get_usecase().get_placement_user(input=input)
|
||||
|
||||
@@ -21,7 +21,7 @@ async def get_views_history(
|
||||
to_date: datetime.datetime | None = None,
|
||||
) -> Page[dto.PostViewsHistoryOutput]:
|
||||
input_data = dto.GetViewsHistoryInput(
|
||||
placement_id=placement_id,
|
||||
publication_id=placement_id,
|
||||
user_id=current_user.user_id,
|
||||
workspace_id=workspace_id,
|
||||
from_date=from_date,
|
||||
|
||||
@@ -10,6 +10,8 @@ if TYPE_CHECKING:
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FetchPlacementPostWorker(WorkerBase):
|
||||
class FetchPublicationPostWorker(WorkerBase):
|
||||
"""Worker that fetches posts from channels and creates Publications from Placements"""
|
||||
|
||||
async def _cycle_func(self) -> None:
|
||||
await deps.get_usecase().fetch_placement_post_cycle(self.config.INTERVAL_SECONDS)
|
||||
await deps.get_usecase().fetch_publication_post_cycle(self.config.INTERVAL_SECONDS)
|
||||
|
||||
@@ -16,24 +16,24 @@ __all__ = (
|
||||
'Channel',
|
||||
'Project',
|
||||
'ProjectStatus',
|
||||
'Purchase',
|
||||
'PurchaseChannel',
|
||||
'PurchaseStatus',
|
||||
'PurchaseChannelStatus',
|
||||
'Placement',
|
||||
'PlacementStatus',
|
||||
'PlacementType',
|
||||
'Publication',
|
||||
'PublicationStatus',
|
||||
'Creative',
|
||||
'replace_invite_link_with_tag',
|
||||
'validate_media_size',
|
||||
'MAX_CREATIVE_MEDIA_BYTES',
|
||||
'Placement',
|
||||
'Post',
|
||||
'Subscription',
|
||||
'PostViewsHistory',
|
||||
'ChannelNotFound',
|
||||
'ProjectNotFound',
|
||||
'CreativeStatus',
|
||||
'PlacementStatus',
|
||||
'SubscriptionStatus',
|
||||
'InviteLinkType',
|
||||
'CostType',
|
||||
'PostViewsAvailability',
|
||||
'LoginToken',
|
||||
'UserNotFound',
|
||||
@@ -48,8 +48,8 @@ __all__ = (
|
||||
'LoginTokenAlreadyUsed',
|
||||
'ProjectNotFound',
|
||||
'ProjectChannelConflict',
|
||||
'PurchaseNotFound',
|
||||
'PurchaseChannelNotFound',
|
||||
'PlacementNotFound',
|
||||
'PublicationNotFound',
|
||||
'ChannelNotFound',
|
||||
'ChannelAlreadyExists',
|
||||
'ChannelNoAdminRights',
|
||||
@@ -59,12 +59,17 @@ __all__ = (
|
||||
'CreativeInviteLinkNotFound',
|
||||
'CreativeMultipleInviteLinks',
|
||||
'CreativeMediaTooLarge',
|
||||
'PlacementNotFound',
|
||||
'UserByUsernameNotFound',
|
||||
)
|
||||
|
||||
from .channel import Channel
|
||||
from .creative import Creative, CreativeStatus, MAX_CREATIVE_MEDIA_BYTES, replace_invite_link_with_tag, validate_media_size
|
||||
from .creative import (
|
||||
MAX_CREATIVE_MEDIA_BYTES,
|
||||
Creative,
|
||||
CreativeStatus,
|
||||
replace_invite_link_with_tag,
|
||||
validate_media_size,
|
||||
)
|
||||
from .error import (
|
||||
ChannelAlreadyExists,
|
||||
ChannelNoAdminRights,
|
||||
@@ -78,10 +83,9 @@ from .error import (
|
||||
LoginTokenExpired,
|
||||
LoginTokenNotFound,
|
||||
PlacementNotFound,
|
||||
ProjectNotFound,
|
||||
ProjectChannelConflict,
|
||||
PurchaseChannelNotFound,
|
||||
PurchaseNotFound,
|
||||
ProjectNotFound,
|
||||
PublicationNotFound,
|
||||
TelegramChannelNotFound,
|
||||
UserByUsernameNotFound,
|
||||
UserNotFound,
|
||||
@@ -93,20 +97,20 @@ from .error import (
|
||||
WorkspaceNotFound,
|
||||
)
|
||||
from .login_token import LoginToken
|
||||
from .placement import Placement, PlacementStatus, PostViewsAvailability
|
||||
from .placement import PostViewsAvailability, Publication, PublicationStatus
|
||||
from .post import Post
|
||||
from .post_views_history import PostViewsHistory
|
||||
from .project import Project, ProjectStatus
|
||||
from .purchase import (
|
||||
CostType,
|
||||
InviteLinkType,
|
||||
Purchase,
|
||||
PurchaseChannel,
|
||||
PurchaseChannelStatus,
|
||||
PurchaseStatus,
|
||||
Placement,
|
||||
PlacementStatus,
|
||||
PlacementType,
|
||||
)
|
||||
from .subscription import Subscription, SubscriptionStatus
|
||||
from .user import User
|
||||
from .telegram_user import TelegramUser
|
||||
from .user import User
|
||||
from .workspace import (
|
||||
PermissionKey,
|
||||
PermissionScopeType,
|
||||
|
||||
@@ -47,16 +47,16 @@ def ProjectChannelConflict() -> HTTPException:
|
||||
return HTTPException(status.HTTP_409_CONFLICT, 'Project channel already exists in target workspace')
|
||||
|
||||
|
||||
def PurchaseNotFound(purchase_id: uuid.UUID | None = None) -> HTTPException:
|
||||
if purchase_id is None:
|
||||
return HTTPException(status.HTTP_404_NOT_FOUND, 'Purchase not found')
|
||||
return HTTPException(status.HTTP_404_NOT_FOUND, f'Purchase {purchase_id} not found')
|
||||
def PlacementNotFound(placement_id: uuid.UUID | None = None) -> HTTPException:
|
||||
if placement_id is None:
|
||||
return HTTPException(status.HTTP_404_NOT_FOUND, 'Placement not found')
|
||||
return HTTPException(status.HTTP_404_NOT_FOUND, f'Placement {placement_id} not found')
|
||||
|
||||
|
||||
def PurchaseChannelNotFound(purchase_channel_id: uuid.UUID | None = None) -> HTTPException:
|
||||
if purchase_channel_id is None:
|
||||
return HTTPException(status.HTTP_404_NOT_FOUND, 'Purchase channel not found')
|
||||
return HTTPException(status.HTTP_404_NOT_FOUND, f'Purchase channel {purchase_channel_id} not found')
|
||||
def PublicationNotFound(publication_id: uuid.UUID | None = None) -> HTTPException:
|
||||
if publication_id is None:
|
||||
return HTTPException(status.HTTP_404_NOT_FOUND, 'Publication not found')
|
||||
return HTTPException(status.HTTP_404_NOT_FOUND, f'Publication {publication_id} not found')
|
||||
|
||||
|
||||
def ChannelAlreadyExists(telegram_id: int) -> HTTPException:
|
||||
@@ -90,16 +90,10 @@ def CreativeMediaTooLarge(max_bytes: int) -> HTTPException:
|
||||
def CreativeInUse(creative_id: uuid.UUID) -> HTTPException:
|
||||
return HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
f'Creative {creative_id} is used in active placements and cannot be deleted',
|
||||
f'Creative {creative_id} is used in active publications and cannot be deleted',
|
||||
)
|
||||
|
||||
|
||||
def PlacementNotFound(placement_id: uuid.UUID | None = None) -> HTTPException:
|
||||
if placement_id is None:
|
||||
return HTTPException(status.HTTP_404_NOT_FOUND, 'Placement not found')
|
||||
return HTTPException(status.HTTP_404_NOT_FOUND, f'Placement {placement_id} not found')
|
||||
|
||||
|
||||
def WorkspaceNotFound(workspace_id: uuid.UUID | None = None) -> HTTPException:
|
||||
if workspace_id is None:
|
||||
return HTTPException(status.HTTP_404_NOT_FOUND, 'Workspace not found')
|
||||
|
||||
@@ -10,10 +10,12 @@ if TYPE_CHECKING:
|
||||
from .creative import Creative
|
||||
from .post import Post
|
||||
from .project import Project
|
||||
from .purchase import PurchaseChannel
|
||||
from .purchase import Placement
|
||||
|
||||
__all__ = ['Publication', 'PublicationStatus', 'PostViewsAvailability']
|
||||
|
||||
|
||||
class PlacementStatus(str, enum.Enum):
|
||||
class PublicationStatus(str, enum.Enum):
|
||||
ACTIVE = 'active' # Пост найден и отслеживается
|
||||
ARCHIVED = 'archived' # Вручную архивировано
|
||||
|
||||
@@ -25,32 +27,34 @@ class PostViewsAvailability(str, enum.Enum):
|
||||
MANUAL = 'manual' # Просмотры вводятся вручную
|
||||
|
||||
|
||||
class Placement(TimestampedModel):
|
||||
class Publication(TimestampedModel):
|
||||
"""Публикация, мониторимая системой (бывший Placement)"""
|
||||
|
||||
wanted_placement_date = fields.DatetimeField(db_column='placement_date')
|
||||
cost = fields.FloatField(null=True)
|
||||
comment = fields.TextField(null=True)
|
||||
|
||||
status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.ACTIVE)
|
||||
status = fields.CharEnumField(PublicationStatus, default=PublicationStatus.ACTIVE)
|
||||
|
||||
project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
|
||||
'models.Project', related_name='placements', on_delete=fields.CASCADE, index=True
|
||||
'models.Project', related_name='publications', 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='publications', on_delete=fields.CASCADE, index=True
|
||||
)
|
||||
purchase_channel: fields.ForeignKeyRelation['PurchaseChannel'] = fields.ForeignKeyField(
|
||||
'models.PurchaseChannel', related_name='placements', on_delete=fields.CASCADE, index=True
|
||||
placement: fields.ForeignKeyRelation['Placement'] = fields.ForeignKeyField(
|
||||
'models.Placement', related_name='publications', on_delete=fields.CASCADE, index=True
|
||||
)
|
||||
|
||||
post: fields.ForeignKeyRelation['Post'] | None = fields.ForeignKeyField(
|
||||
'models.Post', related_name='placements', on_delete=fields.SET_NULL, null=True, index=True
|
||||
'models.Post', related_name='publications', on_delete=fields.SET_NULL, null=True, index=True
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
project_id: UUID
|
||||
creative_id: UUID
|
||||
post_id: UUID | None
|
||||
purchase_channel_id: UUID
|
||||
placement_id: UUID
|
||||
|
||||
class Meta:
|
||||
table = 'placement'
|
||||
table = 'publication'
|
||||
|
||||
@@ -4,8 +4,8 @@ from uuid import UUID
|
||||
|
||||
from tortoise import fields
|
||||
|
||||
from .purchase import InviteLinkType
|
||||
from .base import TimestampedModel
|
||||
from .purchase import InviteLinkType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .channel import Channel
|
||||
|
||||
@@ -11,10 +11,7 @@ if TYPE_CHECKING:
|
||||
from .creative import Creative
|
||||
from .project import Project
|
||||
|
||||
|
||||
class PurchaseStatus(enum.StrEnum):
|
||||
ACTIVE = 'active'
|
||||
ARCHIVED = 'archived'
|
||||
__all__ = ['Placement', 'PlacementStatus', 'PlacementType', 'InviteLinkType', 'CostType']
|
||||
|
||||
|
||||
class CostType(enum.StrEnum):
|
||||
@@ -22,7 +19,7 @@ class CostType(enum.StrEnum):
|
||||
CPM = 'cpm'
|
||||
|
||||
|
||||
class PurchaseChannelStatus(enum.StrEnum):
|
||||
class PlacementStatus(enum.StrEnum):
|
||||
PLANNED = 'planned'
|
||||
APPROVED = 'approved'
|
||||
REJECTED = 'rejected'
|
||||
@@ -30,66 +27,71 @@ class PurchaseChannelStatus(enum.StrEnum):
|
||||
COMPLETED = 'completed'
|
||||
|
||||
|
||||
class PurchaseType(enum.StrEnum):
|
||||
class PlacementType(enum.StrEnum):
|
||||
SELF_PROMO = 'self_promo'
|
||||
STANDARD = 'standard'
|
||||
|
||||
|
||||
class Purchase(TimestampedModel):
|
||||
status = fields.CharEnumField(PurchaseStatus, default=PurchaseStatus.ACTIVE)
|
||||
placement_at = fields.DatetimeField(null=True)
|
||||
payment_at = fields.DatetimeField(null=True)
|
||||
cost_type = fields.CharEnumField(CostType, null=True, max_length=8)
|
||||
cost_value = fields.FloatField(null=True)
|
||||
cost_before_bargain = fields.FloatField(null=True)
|
||||
purchase_type = fields.CharEnumField(PurchaseType, null=True, max_length=16)
|
||||
format = fields.TextField(null=True)
|
||||
comment = fields.TextField(null=True)
|
||||
|
||||
project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
|
||||
'models.Project', related_name='purchases', on_delete=fields.CASCADE, index=True
|
||||
)
|
||||
creative: fields.ForeignKeyRelation['Creative'] = fields.ForeignKeyField(
|
||||
'models.Creative', related_name='purchases', on_delete=fields.CASCADE, index=True
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
project_id: uuid.UUID
|
||||
creative_id: uuid.UUID
|
||||
|
||||
class Meta:
|
||||
table = 'purchase'
|
||||
indexes = (('project', 'status'),)
|
||||
|
||||
|
||||
class InviteLinkType(enum.StrEnum):
|
||||
PUBLIC = 'public' # открытая ссылка
|
||||
APPROVAL = 'approval' # с одобрением ботом
|
||||
|
||||
|
||||
class PurchaseChannel(TimestampedModel):
|
||||
status = fields.CharEnumField(PurchaseChannelStatus, default=PurchaseChannelStatus.PLANNED)
|
||||
class Placement(TimestampedModel):
|
||||
"""Размещение, управляемое пользователем (бывший PurchaseChannel + Purchase)"""
|
||||
|
||||
status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.PLANNED)
|
||||
placement_at = fields.DatetimeField(null=True)
|
||||
payment_at = fields.DatetimeField(null=True)
|
||||
cost_type = fields.CharEnumField(CostType, null=True, max_length=8)
|
||||
cost_value = fields.FloatField(null=True)
|
||||
cost_before_bargain = fields.FloatField(null=True)
|
||||
placement_type = fields.CharEnumField(PlacementType, null=True, max_length=16)
|
||||
format = fields.TextField(null=True)
|
||||
comment = fields.TextField(null=True)
|
||||
|
||||
invite_link = fields.CharField(max_length=512)
|
||||
invite_link_type = fields.CharEnumField(InviteLinkType)
|
||||
|
||||
purchase: fields.ForeignKeyRelation['Purchase'] = fields.ForeignKeyField(
|
||||
'models.Purchase', related_name='channels', 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
|
||||
)
|
||||
channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField(
|
||||
'models.Channel', related_name='purchase_channels', on_delete=fields.CASCADE, index=True
|
||||
'models.Channel', related_name='placements', on_delete=fields.CASCADE, index=True
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
purchase_id: uuid.UUID
|
||||
project_id: uuid.UUID
|
||||
creative_id: uuid.UUID
|
||||
channel_id: uuid.UUID
|
||||
|
||||
class Meta:
|
||||
table = 'purchase_channel'
|
||||
unique_together = (('purchase_id', 'channel_id'),)
|
||||
table = 'placement'
|
||||
indexes = (('project', 'status'),)
|
||||
|
||||
|
||||
# DEPRECATED: старые модели, будут удалены после миграции
|
||||
|
||||
# class PurchaseStatus(enum.StrEnum):
|
||||
# ACTIVE = 'active'
|
||||
# ARCHIVED = 'archived'
|
||||
#
|
||||
# class PurchaseChannelStatus(enum.StrEnum):
|
||||
# PLANNED = 'planned'
|
||||
# APPROVED = 'approved'
|
||||
# REJECTED = 'rejected'
|
||||
# IN_PROGRESS = 'in_progress'
|
||||
# COMPLETED = 'completed'
|
||||
#
|
||||
# class PurchaseType(enum.StrEnum):
|
||||
# SELF_PROMO = 'self_promo'
|
||||
# STANDARD = 'standard'
|
||||
#
|
||||
# class Purchase(TimestampedModel):
|
||||
# ...
|
||||
#
|
||||
# class PurchaseChannel(TimestampedModel):
|
||||
# ...
|
||||
|
||||
@@ -7,7 +7,7 @@ from tortoise import fields
|
||||
from .base import TimestampedModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .placement import Placement
|
||||
from .placement import Publication
|
||||
from .telegram_user import TelegramUser
|
||||
|
||||
|
||||
@@ -21,17 +21,17 @@ class Subscription(TimestampedModel):
|
||||
status = fields.CharEnumField(SubscriptionStatus, default=SubscriptionStatus.ACTIVE)
|
||||
unsubscribed_at = fields.DatetimeField(null=True)
|
||||
|
||||
placement: fields.ForeignKeyRelation['Placement'] = fields.ForeignKeyField(
|
||||
'models.Placement', related_name='subscriptions', on_delete=fields.CASCADE, index=True
|
||||
publication: fields.ForeignKeyRelation['Publication'] = fields.ForeignKeyField(
|
||||
'models.Publication', related_name='subscriptions', on_delete=fields.CASCADE, index=True
|
||||
)
|
||||
telegram_user: fields.ForeignKeyRelation['TelegramUser'] = fields.ForeignKeyField(
|
||||
'models.TelegramUser', related_name='subscriptions', on_delete=fields.CASCADE, index=True
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
placement_id: UUID
|
||||
publication_id: UUID
|
||||
telegram_user_id: UUID
|
||||
|
||||
class Meta:
|
||||
table = 'subscription'
|
||||
unique_together = (('placement_id', 'telegram_user_id'),)
|
||||
unique_together = (('publication_id', 'telegram_user_id'),)
|
||||
|
||||
@@ -11,8 +11,8 @@ from .base import TimestampedModel
|
||||
if TYPE_CHECKING:
|
||||
from .channel import Channel
|
||||
from .creative import Creative
|
||||
from .placement import Placement
|
||||
from .project import Project
|
||||
from .purchase import Placement
|
||||
from .user import User
|
||||
|
||||
|
||||
|
||||
@@ -17,16 +17,18 @@ __all__ = (
|
||||
'GetChannelsInput',
|
||||
'GetChannelsOutput',
|
||||
'AttachChannelToWorkspaceInput',
|
||||
'PurchaseOutput',
|
||||
'PurchaseChannelOutput',
|
||||
'PurchaseDetails',
|
||||
'PurchaseChannelDetails',
|
||||
'PlacementOutput',
|
||||
'PlacementDetails',
|
||||
'CostInfo',
|
||||
'CreatePurchaseInput',
|
||||
'CreatePurchaseChannelInput',
|
||||
'GetPurchasesInput',
|
||||
'GetPurchasesOutput',
|
||||
'GetPurchaseInput',
|
||||
'CreatePlacementsInput',
|
||||
'CreatePlacementChannelInput',
|
||||
'GetPlacementsInput',
|
||||
'GetPlacementsOutput',
|
||||
'GetPlacementInput',
|
||||
'PublicationOutput',
|
||||
'GetPublicationsInput',
|
||||
'GetPublicationsOutput',
|
||||
'GetPublicationInput',
|
||||
'CreativeButton',
|
||||
'CreativeOutput',
|
||||
'GetCreativesInput',
|
||||
@@ -35,10 +37,6 @@ __all__ = (
|
||||
'CreateCreativeInput',
|
||||
'UpdateCreativeInput',
|
||||
'DeleteCreativeInput',
|
||||
'PlacementOutput',
|
||||
'GetPlacementsInput',
|
||||
'GetPlacementsOutput',
|
||||
'GetPlacementInput',
|
||||
'PostViewsHistoryOutput',
|
||||
'GetViewsHistoryInput',
|
||||
'GetViewsHistoryOutput',
|
||||
@@ -133,7 +131,12 @@ from .creative import (
|
||||
GetCreativesOutput,
|
||||
UpdateCreativeInput,
|
||||
)
|
||||
from .placement import GetPlacementInput, GetPlacementsInput, GetPlacementsOutput, PlacementOutput
|
||||
from .placement import (
|
||||
GetPublicationInput,
|
||||
GetPublicationsInput,
|
||||
GetPublicationsOutput,
|
||||
PublicationOutput,
|
||||
)
|
||||
from .project import (
|
||||
ArchiveProjectInput,
|
||||
ChannelBotPermissions,
|
||||
@@ -149,15 +152,13 @@ from .project import (
|
||||
)
|
||||
from .purchase import (
|
||||
CostInfo,
|
||||
CreatePurchaseChannelInput,
|
||||
CreatePurchaseInput,
|
||||
GetPurchaseInput,
|
||||
GetPurchasesInput,
|
||||
GetPurchasesOutput,
|
||||
PurchaseChannelDetails,
|
||||
PurchaseChannelOutput,
|
||||
PurchaseDetails,
|
||||
PurchaseOutput,
|
||||
CreatePlacementChannelInput,
|
||||
CreatePlacementsInput,
|
||||
GetPlacementInput,
|
||||
GetPlacementsInput,
|
||||
GetPlacementsOutput,
|
||||
PlacementDetails,
|
||||
PlacementOutput,
|
||||
)
|
||||
from .user import UserOutput
|
||||
from .validate_login_token import ValidateLoginTokenInput, ValidateLoginTokenOutput
|
||||
|
||||
@@ -6,28 +6,29 @@ import pydantic
|
||||
from src import domain
|
||||
|
||||
|
||||
class PlacementOutput(pydantic.BaseModel):
|
||||
class PublicationOutput(pydantic.BaseModel):
|
||||
"""Публикация - фактический пост, мониторимый системой"""
|
||||
|
||||
id: uuid.UUID
|
||||
project_id: uuid.UUID
|
||||
project_channel_title: str | None
|
||||
placement_channel_id: uuid.UUID
|
||||
placement_channel_id: uuid.UUID # Канал, где размещен пост
|
||||
placement_channel_title: str | None
|
||||
creative_id: uuid.UUID
|
||||
creative_name: str
|
||||
placement_date: datetime.datetime
|
||||
placement_date: datetime.datetime # wanted_placement_date
|
||||
cost: float | None
|
||||
comment: str | None
|
||||
ad_post_url: str | None
|
||||
invite_link_type: domain.purchase.InviteLinkType
|
||||
invite_link: str
|
||||
status: domain.PlacementStatus
|
||||
status: domain.PublicationStatus
|
||||
subscriptions_count: int
|
||||
purchase_id: uuid.UUID | None = None
|
||||
purchase_channel_id: uuid.UUID | None = None
|
||||
placement_id: uuid.UUID # Ссылка на Placement
|
||||
created_at: datetime.datetime
|
||||
|
||||
|
||||
class GetPlacementsInput(pydantic.BaseModel):
|
||||
class GetPublicationsInput(pydantic.BaseModel):
|
||||
user_id: uuid.UUID
|
||||
workspace_id: uuid.UUID
|
||||
project_id: uuid.UUID | None = None
|
||||
@@ -36,31 +37,31 @@ class GetPlacementsInput(pydantic.BaseModel):
|
||||
include_archived: bool = False
|
||||
|
||||
|
||||
class GetPlacementsOutput(pydantic.BaseModel):
|
||||
placements: list[PlacementOutput]
|
||||
class GetPublicationsOutput(pydantic.BaseModel):
|
||||
publications: list[PublicationOutput]
|
||||
|
||||
|
||||
class GetPlacementInput(pydantic.BaseModel):
|
||||
placement_id: uuid.UUID
|
||||
class GetPublicationInput(pydantic.BaseModel):
|
||||
publication_id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
workspace_id: uuid.UUID
|
||||
|
||||
|
||||
class CreatePlacementInput(pydantic.BaseModel):
|
||||
purchase_channel_id: uuid.UUID
|
||||
class CreatePublicationInput(pydantic.BaseModel):
|
||||
placement_id: uuid.UUID
|
||||
placement_date: datetime.datetime
|
||||
cost: float | None = None
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
class UpdatePlacementInput(pydantic.BaseModel):
|
||||
class UpdatePublicationInput(pydantic.BaseModel):
|
||||
placement_date: datetime.datetime | None = None
|
||||
cost: float | None = None
|
||||
comment: str | None = None
|
||||
status: domain.PlacementStatus | None = None
|
||||
status: domain.PublicationStatus | None = None
|
||||
|
||||
|
||||
class DeletePlacementInput(pydantic.BaseModel):
|
||||
placement_id: uuid.UUID
|
||||
class DeletePublicationInput(pydantic.BaseModel):
|
||||
publication_id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
workspace_id: uuid.UUID
|
||||
|
||||
@@ -3,7 +3,7 @@ import uuid
|
||||
|
||||
import pydantic
|
||||
|
||||
from src.domain.purchase import CostType, InviteLinkType, PurchaseChannelStatus, PurchaseStatus, PurchaseType
|
||||
from src.domain.purchase import CostType, InviteLinkType, PlacementStatus, PlacementType
|
||||
|
||||
from .channel import ChannelOutput
|
||||
|
||||
@@ -13,66 +13,55 @@ class CostInfo(pydantic.BaseModel):
|
||||
value: float
|
||||
|
||||
|
||||
class PurchaseDetails(pydantic.BaseModel):
|
||||
class PlacementDetails(pydantic.BaseModel):
|
||||
placement_at: datetime.datetime | None = None
|
||||
payment_at: datetime.datetime | None = None
|
||||
cost: CostInfo | None = None
|
||||
cost_before_bargain: float | None = None
|
||||
purchase_type: PurchaseType | None = None
|
||||
placement_type: PlacementType | None = None
|
||||
format: str | None = None
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
class PurchaseChannelDetails(pydantic.BaseModel):
|
||||
placement_at: datetime.datetime | None = None
|
||||
cost: CostInfo | None = None
|
||||
cost_before_bargain: float | None = None
|
||||
format: str | None = None
|
||||
|
||||
|
||||
class PurchaseChannelOutput(pydantic.BaseModel):
|
||||
class PlacementOutput(pydantic.BaseModel):
|
||||
id: uuid.UUID
|
||||
status: PurchaseChannelStatus
|
||||
status: PlacementStatus
|
||||
comment: str | None = None
|
||||
invite_link: str
|
||||
invite_link_type: InviteLinkType
|
||||
channel: ChannelOutput
|
||||
details: PurchaseChannelDetails | None = None
|
||||
details: PlacementDetails | None = None
|
||||
|
||||
|
||||
class PurchaseOutput(pydantic.BaseModel):
|
||||
id: uuid.UUID
|
||||
status: PurchaseStatus
|
||||
creative_id: uuid.UUID
|
||||
channels: list[PurchaseChannelOutput]
|
||||
details: PurchaseDetails | None = None
|
||||
class CreatePlacementChannelInput(pydantic.BaseModel):
|
||||
"""Input для создания одного placement (channel + детали)"""
|
||||
|
||||
|
||||
class CreatePurchaseChannelInput(pydantic.BaseModel):
|
||||
username: str = pydantic.Field(pattern=r'^[^@]')
|
||||
status: PurchaseChannelStatus | None = None
|
||||
status: PlacementStatus | None = None
|
||||
comment: str | None = None
|
||||
details: PurchaseChannelDetails | None = None
|
||||
details: PlacementDetails | None = None
|
||||
|
||||
|
||||
class CreatePurchaseInput(pydantic.BaseModel):
|
||||
class CreatePlacementsInput(pydantic.BaseModel):
|
||||
"""Input для создания нескольких placements (бывший CreatePurchaseInput)"""
|
||||
|
||||
creative_id: uuid.UUID
|
||||
channels: list[CreatePurchaseChannelInput]
|
||||
details: PurchaseDetails | None = None
|
||||
channels: list[CreatePlacementChannelInput]
|
||||
details: PlacementDetails | None = None # Общие детали для всех placements
|
||||
|
||||
|
||||
class GetPurchasesInput(pydantic.BaseModel):
|
||||
class GetPlacementsInput(pydantic.BaseModel):
|
||||
user_id: uuid.UUID
|
||||
workspace_id: uuid.UUID
|
||||
project_id: uuid.UUID
|
||||
|
||||
|
||||
class GetPurchasesOutput(pydantic.BaseModel):
|
||||
purchases: list[PurchaseOutput]
|
||||
class GetPlacementsOutput(pydantic.BaseModel):
|
||||
placements: list[PlacementOutput]
|
||||
|
||||
|
||||
class GetPurchaseInput(pydantic.BaseModel):
|
||||
class GetPlacementInput(pydantic.BaseModel):
|
||||
user_id: uuid.UUID
|
||||
workspace_id: uuid.UUID
|
||||
project_id: uuid.UUID
|
||||
purchase_id: uuid.UUID
|
||||
placement_id: uuid.UUID
|
||||
|
||||
@@ -15,9 +15,9 @@ class PostViewsHistoryOutput(pydantic.BaseModel):
|
||||
|
||||
|
||||
class GetViewsHistoryInput(pydantic.BaseModel):
|
||||
"""Получить историю просмотров для закупа."""
|
||||
"""Получить историю просмотров для публикации."""
|
||||
|
||||
placement_id: uuid.UUID
|
||||
publication_id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
workspace_id: uuid.UUID
|
||||
from_date: datetime.datetime | None = None # Фильтр: с какой даты
|
||||
|
||||
@@ -16,7 +16,7 @@ from src.adapter.s3 import S3
|
||||
from src.adapter.telegram_bot import TelegramBot
|
||||
from src.config import settings
|
||||
from src.controller.http_v1 import api_router
|
||||
from src.controller.worker.fetch_placement_post import FetchPlacementPostWorker
|
||||
from src.controller.worker.fetch_placement_post import FetchPublicationPostWorker
|
||||
from src.usecase import Usecase
|
||||
|
||||
|
||||
@@ -24,11 +24,11 @@ from src.usecase import Usecase
|
||||
async def lifespan(_: FastAPI) -> AsyncGenerator[None]:
|
||||
await postgres.connect()
|
||||
await s3.connect()
|
||||
await fetch_placement_post_worker.start()
|
||||
await fetch_publication_post_worker.start()
|
||||
|
||||
yield
|
||||
|
||||
await fetch_placement_post_worker.stop()
|
||||
await fetch_publication_post_worker.stop()
|
||||
await s3.close()
|
||||
await postgres.close()
|
||||
|
||||
@@ -51,7 +51,7 @@ usecase = Usecase(
|
||||
)
|
||||
deps.set_usecase(usecase)
|
||||
|
||||
fetch_placement_post_worker = FetchPlacementPostWorker(config=WorkerConfig(INTERVAL_SECONDS=5))
|
||||
fetch_publication_post_worker = FetchPublicationPostWorker(config=WorkerConfig(INTERVAL_SECONDS=5))
|
||||
|
||||
app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
|
||||
@@ -26,9 +26,9 @@ from .creative.delete_creative import delete_creative
|
||||
from .creative.get_creative import get_creative
|
||||
from .creative.get_creatives import get_creatives
|
||||
from .creative.update_creative import update_creative
|
||||
from .placement.fetch_placement_post_cycle import fetch_placement_post_cycle
|
||||
from .placement.get_placement import get_placement
|
||||
from .placement.get_placements import get_placements
|
||||
from .placement.fetch_publication_post_cycle import fetch_publication_post_cycle
|
||||
from .placement.get_publication import get_publication
|
||||
from .placement.get_publications import get_publications
|
||||
from .project.archive_project import archive_project
|
||||
from .project.delete_project import delete_project
|
||||
from .project.disconnect_project_by_tg_id import disconnect_project_by_tg_id
|
||||
@@ -38,9 +38,9 @@ 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.create_purchase import create_purchase
|
||||
from .purchase.get_purchase import get_purchase
|
||||
from .purchase.get_purchases import get_purchases
|
||||
from .purchase.create_placements import create_placements
|
||||
from .purchase.get_placement import get_placement_user
|
||||
from .purchase.get_placements import get_placements
|
||||
from .subscription.handle_subscription import handle_subscription
|
||||
from .subscription.handle_unsubscription import handle_unsubscription
|
||||
from .views.get_views_history import get_views_history
|
||||
@@ -171,43 +171,18 @@ class Database(typing.Protocol):
|
||||
|
||||
async def check_channel_exists_in_workspace(self, channel_id: UUID, workspace_id: UUID) -> bool: ...
|
||||
|
||||
async def get_active_purchase(self, project_id: UUID, creative_id: UUID) -> domain.Purchase | None: ...
|
||||
# Placement methods (user-managed, бывший PurchaseChannel)
|
||||
async def create_placement(self, placement: domain.Placement) -> None: ...
|
||||
|
||||
async def get_purchase(self, workspace_id: UUID, purchase_id: UUID) -> domain.Purchase | None: ...
|
||||
async def create_placements(self, placements: list[domain.Placement]) -> None: ...
|
||||
|
||||
async def get_project_purchases(self, workspace_id: UUID, project_id: UUID) -> list[domain.Purchase]: ...
|
||||
async def get_placement(self, workspace_id: UUID, placement_id: UUID) -> domain.Placement | None: ...
|
||||
|
||||
async def create_purchase(self, purchase: domain.Purchase) -> None: ...
|
||||
async def get_project_placements(
|
||||
self, workspace_id: UUID, project_id: UUID, include_archived: bool = False
|
||||
) -> list[domain.Placement]: ...
|
||||
|
||||
async def get_purchase_channels(self, purchase_id: UUID) -> list[domain.PurchaseChannel]: ...
|
||||
|
||||
async def get_purchase_channel(
|
||||
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(
|
||||
self,
|
||||
purchase_id: UUID,
|
||||
channel_id: UUID,
|
||||
invite_link: str,
|
||||
invite_link_type: domain.purchase.InviteLinkType,
|
||||
*,
|
||||
status: domain.PurchaseChannelStatus | None = None,
|
||||
comment: str | None = None,
|
||||
placement_at: datetime.datetime | None = None,
|
||||
cost_type: domain.purchase.CostType | None = None,
|
||||
cost_value: float | None = None,
|
||||
cost_before_bargain: float | None = None,
|
||||
format: str | None = None,
|
||||
) -> domain.PurchaseChannel: ...
|
||||
|
||||
async def update_purchase_channel(self, purchase_channel: domain.PurchaseChannel) -> None: ...
|
||||
|
||||
async def remove_channel_from_purchase(self, purchase_id: UUID, channel_id: UUID) -> None: ...
|
||||
async def update_placement_user(self, placement: domain.Placement) -> None: ...
|
||||
|
||||
async def get_creative(self, workspace_id: UUID, creative_id: UUID) -> domain.Creative | None: ...
|
||||
|
||||
@@ -223,9 +198,10 @@ class Database(typing.Protocol):
|
||||
|
||||
async def delete_creative(self, creative_id: UUID) -> None: ...
|
||||
|
||||
async def get_placement(self, workspace_id: UUID, placement_id: UUID) -> domain.Placement | None: ...
|
||||
# Publication methods (system-managed, бывший Placement)
|
||||
async def get_publication(self, workspace_id: UUID, publication_id: UUID) -> domain.Publication | None: ...
|
||||
|
||||
async def get_workspace_placements(
|
||||
async def get_workspace_publications(
|
||||
self,
|
||||
workspace_id: UUID,
|
||||
project_id: UUID | None = None,
|
||||
@@ -235,27 +211,28 @@ class Database(typing.Protocol):
|
||||
allowed_project_ids: set[UUID] | None = None,
|
||||
date_from: datetime.datetime | None = None,
|
||||
date_to: datetime.datetime | None = None,
|
||||
) -> list[domain.Placement]: ...
|
||||
) -> list[domain.Publication]: ...
|
||||
|
||||
async def create_placement(self, placement: domain.Placement) -> None: ...
|
||||
async def create_publication(self, publication: domain.Publication) -> None: ...
|
||||
|
||||
async def update_placement(self, placement: domain.Placement) -> None: ...
|
||||
async def update_publication(self, publication: domain.Publication) -> None: ...
|
||||
|
||||
async def delete_placement(self, placement_id: UUID) -> None: ...
|
||||
async def delete_publication(self, publication_id: UUID) -> None: ...
|
||||
|
||||
async def get_placement_by_invite_link(self, invite_link: str) -> domain.Placement | None: ...
|
||||
async def get_publication_by_invite_link(self, invite_link: str) -> domain.Publication | None: ...
|
||||
|
||||
# Subscription methods
|
||||
async def create_subscription(self, subscription: domain.Subscription) -> None: ...
|
||||
|
||||
async def get_subscription_by_subscriber_and_placement(
|
||||
self, telegram_user_id: UUID, placement_id: UUID
|
||||
async def get_subscription_by_subscriber_and_publication(
|
||||
self, telegram_user_id: UUID, publication_id: UUID
|
||||
) -> domain.Subscription | None: ...
|
||||
|
||||
async def update_subscription(self, subscription: domain.Subscription) -> None: ...
|
||||
|
||||
async def get_subscriptions_for_placements(
|
||||
async def get_subscriptions_for_publications(
|
||||
self,
|
||||
placement_ids: list[UUID],
|
||||
publication_ids: list[UUID],
|
||||
*,
|
||||
date_from: datetime.datetime | None = None,
|
||||
date_to: datetime.datetime | None = None,
|
||||
@@ -270,15 +247,17 @@ class Database(typing.Protocol):
|
||||
) -> domain.Subscription | None: ...
|
||||
|
||||
# Count methods
|
||||
async def count_placements_by_creative(self, creative_id: UUID) -> int: ...
|
||||
async def count_publications_by_creative(self, creative_id: UUID) -> int: ...
|
||||
|
||||
async def count_subscriptions_by_placement(self, placement_id: UUID) -> int: ...
|
||||
async def count_subscriptions_by_publication(self, publication_id: UUID) -> int: ...
|
||||
|
||||
async def count_placements_by_creative_batch(self, creative_ids: list[UUID]) -> dict[UUID, int]: ...
|
||||
async def count_publications_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 count_subscriptions_by_publication_batch(
|
||||
self, publication_ids: list[UUID]
|
||||
) -> dict[UUID, int]: ...
|
||||
|
||||
async def has_placements_for_creative(self, creative_id: UUID) -> bool: ...
|
||||
async def has_publications_for_creative(self, creative_id: UUID) -> bool: ...
|
||||
|
||||
# Post methods
|
||||
async def create_post(self, post: domain.Post) -> None: ...
|
||||
@@ -409,15 +388,6 @@ class Usecase:
|
||||
|
||||
return workspace
|
||||
|
||||
async def ensure_active_purchase(self, project: domain.Project, creative: domain.Creative) -> domain.Purchase:
|
||||
purchase = await self.database.get_active_purchase(project.id, creative.id)
|
||||
if purchase:
|
||||
return purchase
|
||||
|
||||
purchase = domain.Purchase(project_id=project.id, creative_id=creative.id)
|
||||
await self.database.create_purchase(purchase)
|
||||
return purchase
|
||||
|
||||
validate_login_token = validate_login_token
|
||||
create_telegram_login_token = create_telegram_login_token
|
||||
get_jwt_by_telegram_id = get_jwt_by_telegram_id
|
||||
@@ -434,17 +404,20 @@ class Usecase:
|
||||
get_channels = get_channels
|
||||
get_channel = get_channel
|
||||
attach_channel_to_workspace = attach_channel_to_workspace
|
||||
create_purchase = create_purchase
|
||||
get_purchases = get_purchases
|
||||
get_purchase = get_purchase
|
||||
# Placement (user-managed) use cases
|
||||
create_placements = create_placements
|
||||
get_placements = get_placements
|
||||
get_placement_user = get_placement_user
|
||||
# Creative use cases
|
||||
get_creatives = get_creatives
|
||||
get_creative = get_creative
|
||||
create_creative = create_creative
|
||||
update_creative = update_creative
|
||||
delete_creative = delete_creative
|
||||
get_placements = get_placements
|
||||
get_placement = get_placement
|
||||
fetch_placement_post_cycle = fetch_placement_post_cycle
|
||||
# Publication (system-managed) use cases
|
||||
get_publications = get_publications
|
||||
get_publication = get_publication
|
||||
fetch_publication_post_cycle = fetch_publication_post_cycle
|
||||
handle_subscription = handle_subscription
|
||||
handle_unsubscription = handle_unsubscription
|
||||
get_views_history = get_views_history
|
||||
|
||||
@@ -25,32 +25,24 @@ async def get_channel_analytics(
|
||||
if not project:
|
||||
raise domain.ProjectNotFound(input.project_id)
|
||||
context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id)
|
||||
purchase_channels = await self.database.get_purchase_channels_by_project(project.id)
|
||||
channels = [pc.channel for pc in purchase_channels]
|
||||
allowed_project_filter = None
|
||||
else:
|
||||
allowed_project_filter = allowed_project_ids
|
||||
if allowed_project_ids is None:
|
||||
# Получить каналы через Projects
|
||||
projects = await self.database.get_workspace_projects(input.workspace_id)
|
||||
channels = [p.channel for p in projects]
|
||||
else:
|
||||
channels = []
|
||||
|
||||
placements = await self.database.get_workspace_placements(
|
||||
placements = await self.database.get_workspace_publications(
|
||||
input.workspace_id,
|
||||
input.project_id,
|
||||
include_archived=False,
|
||||
allowed_project_ids=allowed_project_filter,
|
||||
)
|
||||
|
||||
if input.project_id is None and allowed_project_ids is not None:
|
||||
channel_map: dict[UUID, domain.Channel] = {}
|
||||
for placement in placements:
|
||||
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())
|
||||
# Collect unique channels from placements
|
||||
channel_map: dict[UUID, domain.Channel] = {}
|
||||
for publication in placements:
|
||||
placement = publication.placement
|
||||
if placement and placement.channel:
|
||||
channel_map[placement.channel_id] = placement.channel
|
||||
channels = list(channel_map.values())
|
||||
|
||||
@dataclass
|
||||
class ChannelStats:
|
||||
@@ -67,22 +59,22 @@ async def get_channel_analytics(
|
||||
|
||||
# Batch fetch subscriptions counts
|
||||
placement_ids = [p.id for p in placements]
|
||||
subscriptions_counts = await self.database.count_subscriptions_by_placement_batch(placement_ids)
|
||||
subscriptions_counts = await self.database.count_subscriptions_by_publication_batch(placement_ids)
|
||||
|
||||
for p in placements:
|
||||
purchase_channel = p.purchase_channel
|
||||
if not purchase_channel:
|
||||
for publication in placements:
|
||||
placement = publication.placement
|
||||
if not placement:
|
||||
continue
|
||||
stats = channel_stats.get(purchase_channel.channel_id)
|
||||
stats = channel_stats.get(placement.channel_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 += publication.cost if publication.cost is not None else 0
|
||||
stats.total_subscriptions += subscriptions_counts.get(publication.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 publication.post and publication.post.id in views_map:
|
||||
views_count = views_map[publication.post.id][0]
|
||||
stats.total_views += views_count
|
||||
|
||||
stats.placements_count += 1
|
||||
|
||||
@@ -33,7 +33,7 @@ async def get_creatives_analytics(
|
||||
include_archived=False,
|
||||
allowed_project_ids=allowed_project_ids,
|
||||
)
|
||||
placements = await self.database.get_workspace_placements(
|
||||
placements = await self.database.get_workspace_publications(
|
||||
input.workspace_id,
|
||||
input.project_id,
|
||||
include_archived=False,
|
||||
@@ -55,7 +55,7 @@ async def get_creatives_analytics(
|
||||
|
||||
# Batch fetch subscriptions counts
|
||||
placement_ids = [p.id for p in placements]
|
||||
subscriptions_counts = await self.database.count_subscriptions_by_placement_batch(placement_ids)
|
||||
subscriptions_counts = await self.database.count_subscriptions_by_publication_batch(placement_ids)
|
||||
|
||||
for p in placements:
|
||||
stats = creative_stats.get(p.creative_id)
|
||||
|
||||
@@ -55,7 +55,7 @@ async def get_overview_analytics(
|
||||
period_length = raw_duration if raw_duration.total_seconds() > 0 else datetime.timedelta(days=1)
|
||||
previous_period_start = input.date_from - period_length
|
||||
|
||||
placements = await self.database.get_workspace_placements(
|
||||
placements = await self.database.get_workspace_publications(
|
||||
input.workspace_id,
|
||||
project_id=input.project_id,
|
||||
include_archived=False,
|
||||
@@ -64,18 +64,18 @@ async def get_overview_analytics(
|
||||
date_to=input.date_to,
|
||||
)
|
||||
|
||||
current_placements: list[domain.Placement] = []
|
||||
previous_placements: list[domain.Placement] = []
|
||||
current_placements: list[domain.Publication] = []
|
||||
previous_placements: list[domain.Publication] = []
|
||||
|
||||
for placement in placements:
|
||||
placement_date = placement.wanted_placement_date
|
||||
for publication in placements:
|
||||
placement_date = publication.wanted_placement_date
|
||||
if placement_date >= input.date_from and placement_date <= input.date_to:
|
||||
current_placements.append(placement)
|
||||
current_placements.append(publication)
|
||||
elif placement_date >= previous_period_start and placement_date < input.date_from:
|
||||
previous_placements.append(placement)
|
||||
previous_placements.append(publication)
|
||||
|
||||
placement_ids = [p.id for p in placements]
|
||||
subscriptions = await self.database.get_subscriptions_for_placements(
|
||||
subscriptions = await self.database.get_subscriptions_for_publications(
|
||||
placement_ids, date_from=previous_period_start, date_to=input.date_to
|
||||
)
|
||||
|
||||
@@ -89,30 +89,30 @@ async def get_overview_analytics(
|
||||
continue
|
||||
|
||||
if created_at >= input.date_from and created_at <= input.date_to:
|
||||
subs_per_placement_current[sub.placement_id] += 1
|
||||
subs_per_placement_current[sub.publication_id] += 1
|
||||
subs_per_day_current[created_at.date()] += 1
|
||||
elif created_at >= previous_period_start and created_at < input.date_from:
|
||||
subs_per_placement_previous[sub.placement_id] += 1
|
||||
subs_per_placement_previous[sub.publication_id] += 1
|
||||
|
||||
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 {}
|
||||
|
||||
def _aggregate_totals(
|
||||
placement_list: list[domain.Placement], subs_per_placement: dict[UUID, int]
|
||||
placement_list: list[domain.Publication], subs_per_placement: dict[UUID, int]
|
||||
) -> tuple[float, int, int]:
|
||||
total_cost = 0.0
|
||||
total_subscriptions = 0
|
||||
total_views = 0
|
||||
|
||||
for placement in placement_list:
|
||||
if placement.cost is not None:
|
||||
total_cost += placement.cost
|
||||
for publication in placement_list:
|
||||
if publication.cost is not None:
|
||||
total_cost += publication.cost
|
||||
|
||||
subs = subs_per_placement.get(placement.id, 0)
|
||||
subs = subs_per_placement.get(publication.id, 0)
|
||||
total_subscriptions += subs
|
||||
|
||||
if placement.post and placement.post.id in views_map:
|
||||
total_views += views_map[placement.post.id][0]
|
||||
if publication.post and publication.post.id in views_map:
|
||||
total_views += views_map[publication.post.id][0]
|
||||
|
||||
return total_cost, total_subscriptions, total_views
|
||||
|
||||
@@ -130,20 +130,20 @@ async def get_overview_analytics(
|
||||
project_spending_map: dict[UUID, float] = defaultdict(float)
|
||||
project_meta: dict[UUID, domain.Project] = {}
|
||||
|
||||
for placement in current_placements:
|
||||
placement_day = placement.wanted_placement_date.date()
|
||||
project_meta[placement.project_id] = placement.project
|
||||
for publication in current_placements:
|
||||
placement_day = publication.wanted_placement_date.date()
|
||||
project_meta[publication.project_id] = publication.project
|
||||
|
||||
cost_value = placement.cost or 0.0
|
||||
cost_value = publication.cost or 0.0
|
||||
cost_per_day[placement_day] += cost_value
|
||||
project_spending_map[placement.project_id] += cost_value
|
||||
subs = subs_per_placement_current.get(placement.id, 0)
|
||||
project_spending_map[publication.project_id] += cost_value
|
||||
subs = subs_per_placement_current.get(publication.id, 0)
|
||||
|
||||
purchase_channel = placement.purchase_channel
|
||||
if purchase_channel:
|
||||
channel_id = purchase_channel.channel_id
|
||||
placement = publication.placement
|
||||
if placement:
|
||||
channel_id = placement.channel_id
|
||||
if channel_id not in channel_stats:
|
||||
channel_stats[channel_id] = ChannelAggregate(channel=purchase_channel.channel)
|
||||
channel_stats[channel_id] = ChannelAggregate(channel=placement.channel)
|
||||
channel_stats[channel_id].total_cost += cost_value
|
||||
channel_stats[channel_id].total_subs += subs
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ async def get_placements_analytics(
|
||||
context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id)
|
||||
allowed_project_ids = None
|
||||
|
||||
placements = await self.database.get_workspace_placements(
|
||||
placements = await self.database.get_workspace_publications(
|
||||
input.workspace_id,
|
||||
input.project_id,
|
||||
include_archived=False,
|
||||
@@ -50,15 +50,15 @@ async def get_placements_analytics(
|
||||
|
||||
# Batch fetch subscriptions counts
|
||||
placement_ids = [p.id for p in placements]
|
||||
subscriptions_counts = await self.database.count_subscriptions_by_placement_batch(placement_ids)
|
||||
subscriptions_counts = await self.database.count_subscriptions_by_publication_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.purchase_channel.channel_id,
|
||||
placement_channel_title=p.purchase_channel.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,
|
||||
|
||||
@@ -60,20 +60,20 @@ def _format_period_label(dt: datetime.datetime, grouping: dto.DateGrouping) -> s
|
||||
raise ValueError('Invalid date grouping')
|
||||
|
||||
|
||||
def _get_grouping_date(placement: domain.Placement, date_grouping: dto.DateGroupingType) -> datetime.datetime:
|
||||
def _get_grouping_date(publication: domain.Publication, date_grouping: dto.DateGroupingType) -> datetime.datetime:
|
||||
match date_grouping:
|
||||
case dto.DateGroupingType.PLACEMENT_DATE:
|
||||
return placement.wanted_placement_date
|
||||
return publication.wanted_placement_date
|
||||
case dto.DateGroupingType.PURCHASE_DATE:
|
||||
if placement.purchase_channel and placement.purchase_channel.purchase:
|
||||
return placement.purchase_channel.purchase.created_at
|
||||
return placement.wanted_placement_date # Fallback
|
||||
if publication.placement:
|
||||
return publication.placement.created_at
|
||||
return publication.wanted_placement_date # Fallback
|
||||
case dto.DateGroupingType.LINK_DATE:
|
||||
if placement.purchase_channel:
|
||||
return placement.purchase_channel.created_at
|
||||
return placement.wanted_placement_date # Fallback
|
||||
if publication.placement:
|
||||
return publication.placement.created_at
|
||||
return publication.wanted_placement_date # Fallback
|
||||
case _:
|
||||
return placement.wanted_placement_date
|
||||
return publication.wanted_placement_date
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -96,7 +96,7 @@ def _calculate_metrics(
|
||||
all_metrics = requested_metrics is None or len(requested_metrics) == 0
|
||||
|
||||
def should_include(metric: dto.ProjectMetrics) -> bool:
|
||||
return all_metrics or metric in requested_metrics
|
||||
return all_metrics or (requested_metrics is not None and metric in requested_metrics)
|
||||
|
||||
metrics = dto.ProjectMetricsData()
|
||||
|
||||
@@ -183,7 +183,7 @@ async def get_projects_analytics(
|
||||
else:
|
||||
allowed_project_ids = set(input.project_ids)
|
||||
|
||||
placements = await self.database.get_workspace_placements(
|
||||
placements = await self.database.get_workspace_publications(
|
||||
input.workspace_id,
|
||||
project_id=None,
|
||||
include_archived=False,
|
||||
@@ -194,28 +194,28 @@ async def get_projects_analytics(
|
||||
|
||||
# Фильтрация по датам на основе date_grouping
|
||||
filtered_placements = []
|
||||
for placement in placements:
|
||||
grouping_date = _get_grouping_date(placement, input.date_grouping)
|
||||
for publication in placements:
|
||||
grouping_date = _get_grouping_date(publication, input.date_grouping)
|
||||
if input.date_from and grouping_date < input.date_from:
|
||||
continue
|
||||
if input.date_to and grouping_date > input.date_to:
|
||||
continue
|
||||
filtered_placements.append(placement)
|
||||
filtered_placements.append(publication)
|
||||
|
||||
# Batch fetch views data
|
||||
post_ids = [p.post.id for p in filtered_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 filtered_placements]
|
||||
subscriptions_counts = await self.database.count_subscriptions_by_placement_batch(placement_ids)
|
||||
publication_ids = [p.id for p in filtered_placements]
|
||||
subscriptions_counts = await self.database.count_subscriptions_by_publication_batch(publication_ids)
|
||||
|
||||
# Группировка по периодам
|
||||
period_data: dict[str, PeriodMetrics] = defaultdict(PeriodMetrics)
|
||||
total_metrics = PeriodMetrics()
|
||||
|
||||
for placement in filtered_placements:
|
||||
grouping_date = _get_grouping_date(placement, input.date_grouping)
|
||||
for publication in filtered_placements:
|
||||
grouping_date = _get_grouping_date(publication, input.date_grouping)
|
||||
period = _format_period(grouping_date, input.grouping)
|
||||
pd = period_data[period]
|
||||
|
||||
@@ -223,29 +223,29 @@ async def get_projects_analytics(
|
||||
pd.purchases_count += 1
|
||||
total_metrics.purchases_count += 1
|
||||
|
||||
cost = placement.cost or 0.0
|
||||
cost = publication.cost or 0.0
|
||||
pd.total_cost += cost
|
||||
total_metrics.total_cost += cost
|
||||
|
||||
subs_count = subscriptions_counts.get(placement.id, 0)
|
||||
subs_count = subscriptions_counts.get(publication.id, 0)
|
||||
pd.total_subscriptions += subs_count
|
||||
pd.clicks_count += subs_count
|
||||
total_metrics.total_subscriptions += subs_count
|
||||
total_metrics.clicks_count += subs_count
|
||||
|
||||
views_count = 0
|
||||
if placement.post and placement.post.id in views_map:
|
||||
views_count = views_map[placement.post.id][0]
|
||||
if publication.post and publication.post.id in views_map:
|
||||
views_count = views_map[publication.post.id][0]
|
||||
pd.total_views += views_count
|
||||
pd.reach_volume += views_count
|
||||
total_metrics.total_views += views_count
|
||||
total_metrics.reach_volume += views_count
|
||||
|
||||
# Расчет скидок
|
||||
purchase_channel = placement.purchase_channel
|
||||
if purchase_channel and purchase_channel.cost_before_bargain and purchase_channel.cost_before_bargain > cost:
|
||||
discount = purchase_channel.cost_before_bargain - cost
|
||||
discount_percent = (discount / purchase_channel.cost_before_bargain) * 100 if purchase_channel.cost_before_bargain > 0 else 0.0
|
||||
placement = publication.placement
|
||||
if placement and placement.cost_before_bargain and placement.cost_before_bargain > cost:
|
||||
discount = placement.cost_before_bargain - cost
|
||||
discount_percent = (discount / placement.cost_before_bargain) * 100 if placement.cost_before_bargain > 0 else 0.0
|
||||
|
||||
pd.total_discounts += discount
|
||||
pd.discount_count += 1
|
||||
|
||||
@@ -46,7 +46,7 @@ async def get_spending_analytics(
|
||||
context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id)
|
||||
allowed_project_ids = None
|
||||
|
||||
placements = await self.database.get_workspace_placements(
|
||||
placements = await self.database.get_workspace_publications(
|
||||
input.workspace_id,
|
||||
input.project_id,
|
||||
include_archived=False,
|
||||
@@ -76,7 +76,7 @@ async def get_spending_analytics(
|
||||
|
||||
# Batch fetch subscriptions counts
|
||||
placement_ids = [p.id for p in filtered]
|
||||
subscriptions_counts = await self.database.count_subscriptions_by_placement_batch(placement_ids)
|
||||
subscriptions_counts = await self.database.count_subscriptions_by_publication_batch(placement_ids)
|
||||
|
||||
# Группировка по периодам
|
||||
period_data: dict[str, PeriodData] = defaultdict(PeriodData)
|
||||
|
||||
@@ -22,9 +22,9 @@ async def delete_creative(self: 'Usecase', input: dto.DeleteCreativeInput) -> No
|
||||
|
||||
context.ensure_project_permission(domain.PermissionKey.PROJECTS_WRITE, creative.project_id)
|
||||
|
||||
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)
|
||||
has_publications = await self.database.has_publications_for_creative(creative.id)
|
||||
if has_publications:
|
||||
log.warning('Creative %s is used in publications and cannot be deleted', input.creative_id)
|
||||
raise domain.CreativeInUse(input.creative_id)
|
||||
|
||||
media_key = creative.media_s3_key
|
||||
|
||||
@@ -20,7 +20,7 @@ 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)
|
||||
placements_count = await self.database.count_publications_by_creative(creative.id)
|
||||
|
||||
return dto.CreativeOutput(
|
||||
id=creative.id,
|
||||
|
||||
@@ -25,7 +25,7 @@ async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> list[d
|
||||
)
|
||||
|
||||
creative_ids = [c.id for c in creatives]
|
||||
placements_counts = await self.database.count_placements_by_creative_batch(creative_ids)
|
||||
placements_counts = await self.database.count_publications_by_creative_batch(creative_ids)
|
||||
|
||||
return [
|
||||
dto.CreativeOutput(
|
||||
|
||||
@@ -69,7 +69,7 @@ async def update_creative(
|
||||
|
||||
await self.database.update_creative(creative)
|
||||
|
||||
placements_count = await self.database.count_placements_by_creative(creative.id)
|
||||
placements_count = await self.database.count_publications_by_creative(creative.id)
|
||||
|
||||
return dto.CreativeOutput(
|
||||
id=creative.id,
|
||||
|
||||
5
src/usecase/placement/__init__.py
Normal file
5
src/usecase/placement/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from .fetch_publication_post_cycle import fetch_publication_post_cycle
|
||||
from .get_publication import get_publication
|
||||
from .get_publications import get_publications
|
||||
|
||||
__all__ = ['fetch_publication_post_cycle', 'get_publication', 'get_publications']
|
||||
@@ -1,63 +0,0 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def fetch_placement_post_cycle(self: 'Usecase', interval_seconds: int) -> None:
|
||||
log.debug('Starting fetch_placement_post_cycle')
|
||||
|
||||
purchase_channels = (
|
||||
await domain.PurchaseChannel.filter(purchase__status=domain.PurchaseStatus.ACTIVE)
|
||||
.prefetch_related('channel', 'purchase')
|
||||
.all()
|
||||
)
|
||||
|
||||
if not purchase_channels:
|
||||
log.debug('No active purchase channels found')
|
||||
return
|
||||
|
||||
created_count = 0
|
||||
|
||||
for purchase_channel in purchase_channels:
|
||||
if purchase_channel.channel is None or purchase_channel.purchase is None:
|
||||
log.warning('Purchase channel %s missing channel or purchase, skipping', purchase_channel.id)
|
||||
continue
|
||||
|
||||
posts = await domain.Post.filter(
|
||||
channel_id=purchase_channel.channel_id,
|
||||
text__contains=purchase_channel.invite_link,
|
||||
deleted_from_channel_at__isnull=True,
|
||||
).all()
|
||||
|
||||
for post in posts:
|
||||
existing = await domain.Placement.filter(post_id=post.id).first()
|
||||
if existing:
|
||||
continue
|
||||
|
||||
placement = domain.Placement(
|
||||
wanted_placement_date=post.created_at,
|
||||
cost=None,
|
||||
comment=None,
|
||||
project_id=purchase_channel.purchase.project_id,
|
||||
creative_id=purchase_channel.purchase.creative_id,
|
||||
purchase_channel_id=purchase_channel.id,
|
||||
post=post,
|
||||
status=domain.PlacementStatus.ACTIVE,
|
||||
)
|
||||
|
||||
await self.database.create_placement(placement)
|
||||
created_count += 1
|
||||
log.info(
|
||||
'Created placement %s for purchase_channel %s from post %s',
|
||||
placement.id,
|
||||
purchase_channel.id,
|
||||
post.id,
|
||||
)
|
||||
|
||||
log.debug('Fetch placement post cycle completed. Created %s placements', created_count)
|
||||
64
src/usecase/placement/fetch_publication_post_cycle.py
Normal file
64
src/usecase/placement/fetch_publication_post_cycle.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def fetch_publication_post_cycle(self: 'Usecase', interval_seconds: int) -> None:
|
||||
"""Находит посты в каналах по invite_link из Placement и создает Publication"""
|
||||
log.debug('Starting fetch_publication_post_cycle')
|
||||
|
||||
# Получаем все approved Placements с invite_link
|
||||
placements = (
|
||||
await domain.Placement.filter(
|
||||
status__in=[domain.PlacementStatus.APPROVED, domain.PlacementStatus.IN_PROGRESS]
|
||||
)
|
||||
.prefetch_related('channel', 'project')
|
||||
.all()
|
||||
)
|
||||
|
||||
if not placements:
|
||||
log.debug('No active placements found')
|
||||
return
|
||||
|
||||
created_count = 0
|
||||
|
||||
for placement in placements:
|
||||
if placement.channel is None or placement.invite_link is None:
|
||||
log.warning('Placement %s missing channel or invite_link, skipping', placement.id)
|
||||
continue
|
||||
|
||||
# Ищем посты в канале, содержащие invite_link из placement
|
||||
posts = await domain.Post.filter(
|
||||
channel_id=placement.channel_id,
|
||||
text__contains=placement.invite_link,
|
||||
deleted_from_channel_at__isnull=True,
|
||||
).all()
|
||||
|
||||
for post in posts:
|
||||
# Проверяем, не создана ли уже публикация для этого поста
|
||||
existing = await domain.Publication.filter(post_id=post.id).first()
|
||||
if existing:
|
||||
continue
|
||||
|
||||
publication = domain.Publication(
|
||||
placement_id=placement.id,
|
||||
post_id=post.id,
|
||||
status=domain.PublicationStatus.ACTIVE,
|
||||
)
|
||||
|
||||
await self.database.create_publication(publication)
|
||||
created_count += 1
|
||||
log.info(
|
||||
'Created publication %s for placement %s from post %s',
|
||||
publication.id,
|
||||
placement.id,
|
||||
post.id,
|
||||
)
|
||||
|
||||
log.debug('Fetch publication post cycle completed. Created %s publications', created_count)
|
||||
@@ -1,51 +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 get_placement(self: 'Usecase', input: dto.GetPlacementInput) -> dto.PlacementOutput:
|
||||
context = await self.ensure_workspace_permission(
|
||||
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
|
||||
)
|
||||
|
||||
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_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)
|
||||
|
||||
return dto.PlacementOutput(
|
||||
id=placement.id,
|
||||
project_id=placement.project_id,
|
||||
project_channel_title=placement.project.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=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,
|
||||
)
|
||||
@@ -1,66 +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 get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> list[dto.PlacementOutput]:
|
||||
context = await self.ensure_workspace_permission(
|
||||
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
|
||||
)
|
||||
|
||||
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.PLACEMENTS_READ)
|
||||
|
||||
if input.project_id is not None:
|
||||
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, input.project_id)
|
||||
allowed_project_ids = None
|
||||
|
||||
placements = await self.database.get_workspace_placements(
|
||||
input.workspace_id,
|
||||
project_id=input.project_id,
|
||||
placement_channel_id=input.placement_channel_id,
|
||||
creative_id=input.creative_id,
|
||||
include_archived=input.include_archived,
|
||||
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 = 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=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=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,
|
||||
)
|
||||
)
|
||||
|
||||
return placement_outputs
|
||||
55
src/usecase/placement/get_publication.py
Normal file
55
src/usecase/placement/get_publication.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain, dto
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_publication(self: 'Usecase', input: dto.GetPublicationInput) -> dto.PublicationOutput:
|
||||
"""Получить одну публикацию по ID (system-managed post)"""
|
||||
context = await self.ensure_workspace_permission(
|
||||
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
|
||||
)
|
||||
|
||||
publication = await self.database.get_publication(input.workspace_id, input.publication_id)
|
||||
if not publication:
|
||||
log.warning('Publication %s not found for user %s', input.publication_id, input.user_id)
|
||||
raise domain.PublicationNotFound(input.publication_id)
|
||||
|
||||
placement = publication.placement
|
||||
if not placement:
|
||||
log.error('Publication %s missing placement data', publication.id)
|
||||
raise domain.PlacementNotFound(publication.placement_id)
|
||||
|
||||
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, placement.project_id)
|
||||
|
||||
if not placement.channel or not placement.project or not placement.creative:
|
||||
log.error('Publication %s placement missing related data', publication.id)
|
||||
raise domain.PlacementNotFound(placement.id)
|
||||
|
||||
ad_post_url = publication.post.url if publication.post else None
|
||||
subscriptions_count = await self.database.count_subscriptions_by_publication(publication.id)
|
||||
|
||||
return dto.PublicationOutput(
|
||||
id=publication.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 publication.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=publication.status,
|
||||
subscriptions_count=subscriptions_count,
|
||||
placement_id=placement.id,
|
||||
created_at=publication.created_at,
|
||||
)
|
||||
71
src/usecase/placement/get_publications.py
Normal file
71
src/usecase/placement/get_publications.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain, dto
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_publications(self: 'Usecase', input: dto.GetPublicationsInput) -> dto.GetPublicationsOutput:
|
||||
"""Получить список публикаций (system-managed posts)"""
|
||||
context = await self.ensure_workspace_permission(
|
||||
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
|
||||
)
|
||||
|
||||
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.PLACEMENTS_READ)
|
||||
|
||||
if input.project_id is not None:
|
||||
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, input.project_id)
|
||||
allowed_project_ids = None
|
||||
|
||||
publications = await self.database.get_workspace_publications(
|
||||
input.workspace_id,
|
||||
project_id=input.project_id,
|
||||
placement_channel_id=input.placement_channel_id,
|
||||
creative_id=input.creative_id,
|
||||
include_archived=input.include_archived,
|
||||
allowed_project_ids=allowed_project_ids,
|
||||
)
|
||||
|
||||
publication_ids = [p.id for p in publications]
|
||||
subscriptions_counts = await self.database.count_subscriptions_by_publication_batch(publication_ids)
|
||||
|
||||
publication_outputs = []
|
||||
for publication in publications:
|
||||
placement = publication.placement
|
||||
if not placement:
|
||||
log.warning('Publication %s missing placement data', publication.id)
|
||||
continue
|
||||
|
||||
if not placement.channel or not placement.project or not placement.creative:
|
||||
log.warning('Publication %s placement missing related data', publication.id)
|
||||
continue
|
||||
|
||||
ad_post_url = publication.post.url if publication.post else None
|
||||
|
||||
publication_outputs.append(
|
||||
dto.PublicationOutput(
|
||||
id=publication.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 publication.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=publication.status,
|
||||
subscriptions_count=subscriptions_counts.get(publication.id, 0),
|
||||
placement_id=placement.id,
|
||||
created_at=publication.created_at,
|
||||
)
|
||||
)
|
||||
|
||||
return dto.GetPublicationsOutput(publications=publication_outputs)
|
||||
@@ -1,4 +1,3 @@
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain, dto
|
||||
|
||||
@@ -22,7 +22,7 @@ async def update_project_permissions(self: 'Usecase', input: dto.UpdateProjectPe
|
||||
log.warning(f'User with telegram_id {input.user_telegram_id} not found when updating channel permissions')
|
||||
return
|
||||
if user.telegram_user is None:
|
||||
log.warning(f'User %s missing telegram profile when updating permissions', user.id)
|
||||
log.warning('User %s missing telegram profile when updating permissions', user.id)
|
||||
return
|
||||
|
||||
project = await self.database.get_project_for_user_by_telegram(user.id, input.telegram_id)
|
||||
|
||||
5
src/usecase/purchase/__init__.py
Normal file
5
src/usecase/purchase/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from .create_placements import create_placements
|
||||
from .get_placement import get_placement_user
|
||||
from .get_placements import get_placements
|
||||
|
||||
__all__ = ['create_placements', 'get_placement_user', 'get_placements']
|
||||
148
src/usecase/purchase/create_placements.py
Normal file
148
src/usecase/purchase/create_placements.py
Normal file
@@ -0,0 +1,148 @@
|
||||
import logging
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain, dto
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_cost_info(cost_type: domain.CostType | None, cost_value: float | None) -> dto.CostInfo | None:
|
||||
if cost_type is None or cost_value is None:
|
||||
return None
|
||||
return dto.CostInfo(type=cost_type, value=cost_value)
|
||||
|
||||
|
||||
def _build_placement_details(placement: domain.Placement) -> dto.PlacementDetails | None:
|
||||
details = dto.PlacementDetails(
|
||||
placement_at=placement.placement_at,
|
||||
payment_at=placement.payment_at,
|
||||
cost=_build_cost_info(placement.cost_type, placement.cost_value),
|
||||
cost_before_bargain=placement.cost_before_bargain,
|
||||
placement_type=placement.placement_type,
|
||||
format=placement.format,
|
||||
comment=placement.comment,
|
||||
)
|
||||
if details.model_dump(exclude_none=True):
|
||||
return details
|
||||
return None
|
||||
|
||||
|
||||
def _build_placement_output(placement: domain.Placement) -> dto.PlacementOutput:
|
||||
channel = placement.channel
|
||||
if channel is None:
|
||||
log.error('Placement %s has no channel prefetched', placement.id)
|
||||
raise ValueError(f'Placement {placement.id} has no channel')
|
||||
|
||||
return dto.PlacementOutput(
|
||||
id=placement.id,
|
||||
status=placement.status,
|
||||
comment=placement.comment,
|
||||
invite_link=placement.invite_link,
|
||||
invite_link_type=placement.invite_link_type,
|
||||
channel=dto.ChannelOutput(
|
||||
id=channel.id,
|
||||
telegram_id=channel.telegram_id,
|
||||
title=channel.title,
|
||||
username=channel.username,
|
||||
),
|
||||
details=_build_placement_details(placement),
|
||||
)
|
||||
|
||||
|
||||
async def create_placements(
|
||||
self: 'Usecase',
|
||||
project_id: uuid.UUID,
|
||||
workspace_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
input: dto.CreatePlacementsInput,
|
||||
) -> dto.GetPlacementsOutput:
|
||||
"""Create multiple placements for different channels (bulk creation, бывший create_purchase)"""
|
||||
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)
|
||||
|
||||
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] = []
|
||||
|
||||
for channel_input in input.channels:
|
||||
channel = await self.database.get_channel(username=channel_input.username)
|
||||
|
||||
if not channel:
|
||||
parser_response = await self.parser.fetch_telegram_channel(channel_input.username)
|
||||
if not parser_response:
|
||||
raise domain.TelegramChannelNotFound(channel_input.username)
|
||||
|
||||
channel = domain.Channel(
|
||||
username=parser_response.username,
|
||||
telegram_id=parser_response.telegram_id,
|
||||
title=parser_response.title,
|
||||
)
|
||||
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
|
||||
placement = domain.Placement(
|
||||
project_id=project.id,
|
||||
creative_id=creative.id,
|
||||
channel_id=channel.id,
|
||||
invite_link=invite_link,
|
||||
invite_link_type=invite_link_type,
|
||||
status=channel_input.status or domain.PlacementStatus.PLANNED,
|
||||
comment=channel_input.comment,
|
||||
# Приоритет: channel details > общие details
|
||||
placement_at=(channel_details.placement_at if channel_details else None) or (
|
||||
details.placement_at if details else None
|
||||
),
|
||||
payment_at=details.payment_at if details else None,
|
||||
cost_type=(channel_details.cost.type if channel_details and channel_details.cost else None) or (
|
||||
details.cost.type if details and details.cost else None
|
||||
),
|
||||
cost_value=(channel_details.cost.value if channel_details and channel_details.cost else None) or (
|
||||
details.cost.value if details and details.cost else None
|
||||
),
|
||||
cost_before_bargain=(channel_details.cost_before_bargain if channel_details else None) or (
|
||||
details.cost_before_bargain if details else None
|
||||
),
|
||||
placement_type=details.placement_type if details else None,
|
||||
format=(channel_details.format if channel_details else None) or (details.format if details else None),
|
||||
)
|
||||
|
||||
await self.database.create_placement(placement)
|
||||
placement.channel = channel
|
||||
placements.append(placement)
|
||||
|
||||
log.info(
|
||||
'Created %s placements for project %s (creative %s)',
|
||||
len(placements),
|
||||
project.id,
|
||||
creative.id,
|
||||
)
|
||||
|
||||
return dto.GetPlacementsOutput(placements=[_build_placement_output(p) for p in placements])
|
||||
@@ -1,173 +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__)
|
||||
|
||||
|
||||
def _build_cost_info(cost_type: domain.purchase.CostType | None, cost_value: float | None) -> dto.CostInfo | None:
|
||||
if cost_type is None or cost_value is None:
|
||||
return None
|
||||
return dto.CostInfo(type=cost_type, value=cost_value)
|
||||
|
||||
|
||||
def _build_purchase_details(purchase: domain.Purchase) -> dto.PurchaseDetails | None:
|
||||
details = dto.PurchaseDetails(
|
||||
placement_at=purchase.placement_at,
|
||||
payment_at=purchase.payment_at,
|
||||
cost=_build_cost_info(purchase.cost_type, purchase.cost_value),
|
||||
cost_before_bargain=purchase.cost_before_bargain,
|
||||
purchase_type=purchase.purchase_type,
|
||||
format=purchase.format,
|
||||
comment=purchase.comment,
|
||||
)
|
||||
if details.model_dump(exclude_none=True):
|
||||
return details
|
||||
return None
|
||||
|
||||
|
||||
def _build_purchase_channel_details(purchase_channel: domain.PurchaseChannel) -> dto.PurchaseChannelDetails | None:
|
||||
details = dto.PurchaseChannelDetails(
|
||||
placement_at=purchase_channel.placement_at,
|
||||
cost=_build_cost_info(purchase_channel.cost_type, purchase_channel.cost_value),
|
||||
cost_before_bargain=purchase_channel.cost_before_bargain,
|
||||
format=purchase_channel.format,
|
||||
)
|
||||
if details.model_dump(exclude_none=True):
|
||||
return details
|
||||
return None
|
||||
|
||||
|
||||
def _build_purchase_output(
|
||||
purchase: domain.Purchase, purchase_channels: list[domain.PurchaseChannel]
|
||||
) -> dto.PurchaseOutput:
|
||||
channels_output: list[dto.PurchaseChannelOutput] = []
|
||||
for purchase_channel in purchase_channels:
|
||||
channel = purchase_channel.channel
|
||||
if channel is None:
|
||||
log.warning('Purchase channel %s has no channel prefetched', purchase_channel.id)
|
||||
continue
|
||||
channels_output.append(
|
||||
dto.PurchaseChannelOutput(
|
||||
id=purchase_channel.id,
|
||||
status=purchase_channel.status,
|
||||
comment=purchase_channel.comment,
|
||||
invite_link=purchase_channel.invite_link,
|
||||
invite_link_type=purchase_channel.invite_link_type,
|
||||
channel=dto.ChannelOutput(
|
||||
id=channel.id,
|
||||
telegram_id=channel.telegram_id,
|
||||
title=channel.title,
|
||||
username=channel.username,
|
||||
),
|
||||
details=_build_purchase_channel_details(purchase_channel),
|
||||
)
|
||||
)
|
||||
|
||||
return dto.PurchaseOutput(
|
||||
id=purchase.id,
|
||||
status=purchase.status,
|
||||
creative_id=purchase.creative_id,
|
||||
channels=channels_output,
|
||||
details=_build_purchase_details(purchase),
|
||||
)
|
||||
|
||||
|
||||
async def create_purchase(
|
||||
self: 'Usecase',
|
||||
project_id: uuid.UUID,
|
||||
workspace_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
input: dto.CreatePurchaseInput,
|
||||
) -> dto.PurchaseOutput:
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
details = input.details
|
||||
purchase = domain.Purchase(
|
||||
project_id=project.id,
|
||||
creative_id=creative.id,
|
||||
placement_at=details.placement_at if details else None,
|
||||
payment_at=details.payment_at if details else None,
|
||||
cost_type=details.cost.type if details and details.cost else None,
|
||||
cost_value=details.cost.value if details and details.cost else None,
|
||||
cost_before_bargain=details.cost_before_bargain if details else None,
|
||||
purchase_type=details.purchase_type if details else None,
|
||||
format=details.format if details else None,
|
||||
comment=details.comment if details else None,
|
||||
)
|
||||
await self.database.create_purchase(purchase)
|
||||
|
||||
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.purchase.InviteLinkType.APPROVAL
|
||||
|
||||
purchase_channels: list[domain.PurchaseChannel] = []
|
||||
|
||||
for channel_input in input.channels:
|
||||
channel = await self.database.get_channel(username=channel_input.username)
|
||||
|
||||
if not channel:
|
||||
parser_response = await self.parser.fetch_telegram_channel(channel_input.username)
|
||||
if not parser_response:
|
||||
raise domain.TelegramChannelNotFound(channel_input.username)
|
||||
|
||||
channel = domain.Channel(
|
||||
username=parser_response.username,
|
||||
telegram_id=parser_response.telegram_id,
|
||||
title=parser_response.title,
|
||||
)
|
||||
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 purchase %s channel_telegram_id=%s requires_approval=%s invite_link=%s',
|
||||
purchase.id,
|
||||
project.channel.telegram_id,
|
||||
requires_approval,
|
||||
invite_link,
|
||||
)
|
||||
|
||||
channel_details = channel_input.details
|
||||
purchase_channel = await self.database.add_channel_to_purchase(
|
||||
purchase.id,
|
||||
channel.id,
|
||||
invite_link=invite_link,
|
||||
invite_link_type=invite_link_type,
|
||||
status=channel_input.status,
|
||||
comment=channel_input.comment,
|
||||
placement_at=channel_details.placement_at if channel_details else None,
|
||||
cost_type=channel_details.cost.type if channel_details and channel_details.cost else None,
|
||||
cost_value=channel_details.cost.value if channel_details and channel_details.cost else None,
|
||||
cost_before_bargain=channel_details.cost_before_bargain if channel_details else None,
|
||||
format=channel_details.format if channel_details else None,
|
||||
)
|
||||
purchase_channel.channel = channel
|
||||
purchase_channels.append(purchase_channel)
|
||||
|
||||
log.info(
|
||||
'Purchase %s created for project %s (creative %s) with %s channels',
|
||||
purchase.id,
|
||||
project.id,
|
||||
creative.id,
|
||||
len(purchase_channels),
|
||||
)
|
||||
|
||||
return _build_purchase_output(purchase, purchase_channels)
|
||||
35
src/usecase/purchase/get_placement.py
Normal file
35
src/usecase/purchase/get_placement.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain, dto
|
||||
|
||||
from .create_placements import _build_placement_output
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_placement_user(self: 'Usecase', input: dto.GetPlacementInput) -> dto.PlacementOutput:
|
||||
"""Get single placement by ID (user-managed)"""
|
||||
context = await self.ensure_workspace_permission(
|
||||
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
|
||||
)
|
||||
|
||||
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_READ, project.id)
|
||||
|
||||
placement = await self.database.get_placement(input.workspace_id, input.placement_id)
|
||||
if not placement or placement.project_id != project.id:
|
||||
raise domain.PlacementNotFound(input.placement_id)
|
||||
|
||||
# Prefetch channel for output building
|
||||
if placement.channel is None:
|
||||
channel = await self.database.get_channel(channel_id=placement.channel_id)
|
||||
placement.channel = channel
|
||||
|
||||
return _build_placement_output(placement)
|
||||
@@ -2,7 +2,8 @@ import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain, dto
|
||||
from .create_purchase import _build_purchase_output
|
||||
|
||||
from .create_placements import _build_placement_output
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
@@ -10,7 +11,8 @@ if TYPE_CHECKING:
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_purchase(self: 'Usecase', input: dto.GetPurchaseInput) -> dto.PurchaseOutput:
|
||||
async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.GetPlacementsOutput:
|
||||
"""Get all placements for a project (formerly get_purchases)"""
|
||||
context = await self.ensure_workspace_permission(
|
||||
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
|
||||
)
|
||||
@@ -21,10 +23,7 @@ async def get_purchase(self: 'Usecase', input: dto.GetPurchaseInput) -> dto.Purc
|
||||
|
||||
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, project.id)
|
||||
|
||||
purchase = await self.database.get_purchase(input.workspace_id, input.purchase_id)
|
||||
if not purchase or purchase.project_id != project.id:
|
||||
raise domain.PurchaseNotFound(input.purchase_id)
|
||||
placements = await self.database.get_project_placements(input.workspace_id, project.id)
|
||||
log.debug('Fetched %s placements for project %s', len(placements), project.id)
|
||||
|
||||
channels = await self.database.get_purchase_channels(purchase.id)
|
||||
|
||||
return _build_purchase_output(purchase, channels)
|
||||
return dto.GetPlacementsOutput(placements=[_build_placement_output(p) for p in placements])
|
||||
@@ -1,32 +0,0 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain, dto
|
||||
from .create_purchase import _build_purchase_output
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_purchases(self: 'Usecase', input: dto.GetPurchasesInput) -> list[dto.PurchaseOutput]:
|
||||
context = await self.ensure_workspace_permission(
|
||||
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
|
||||
)
|
||||
|
||||
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_READ, project.id)
|
||||
|
||||
purchases = await self.database.get_project_purchases(input.workspace_id, project.id)
|
||||
log.debug('Fetched %s purchases for project %s', len(purchases), project.id)
|
||||
|
||||
output: list[dto.PurchaseOutput] = []
|
||||
for purchase in purchases:
|
||||
channels = await self.database.get_purchase_channels(purchase.id)
|
||||
output.append(_build_purchase_output(purchase, channels))
|
||||
|
||||
return output
|
||||
@@ -17,9 +17,13 @@ async def handle_subscription(
|
||||
first_name: str | None = None,
|
||||
last_name: str | None = None,
|
||||
) -> None:
|
||||
placement = await self.database.get_placement_by_invite_link(invite_link)
|
||||
if not placement:
|
||||
log.warning('Placement not found for invite_link: %s', invite_link)
|
||||
publication = await self.database.get_publication_by_invite_link(invite_link)
|
||||
if not publication:
|
||||
log.warning('Publication not found for invite_link: %s', invite_link)
|
||||
return
|
||||
|
||||
if not publication.placement:
|
||||
log.error('Publication %s has no placement', publication.id)
|
||||
return
|
||||
|
||||
subscriber = await self.database.get_telegram_user(telegram_id=user_telegram_id)
|
||||
@@ -38,26 +42,26 @@ async def handle_subscription(
|
||||
await self.database.update_telegram_user(subscriber)
|
||||
|
||||
active_subscription = await self.database.get_active_subscription_by_subscriber_and_project(
|
||||
subscriber.id, placement.project_id
|
||||
subscriber.id, publication.placement.project_id
|
||||
)
|
||||
|
||||
if active_subscription:
|
||||
# Пользователь уже подписан на канал через другой placement
|
||||
# Пользователь уже подписан на канал через другую публикацию
|
||||
# Это не должно случиться (Telegram не даст подписаться дважды),
|
||||
# но если случилось - логируем и игнорируем
|
||||
log.warning(
|
||||
'User %s (telegram_id: %s) already has active subscription to channel %s via placement %s, '
|
||||
'ignoring new subscription attempt via placement %s',
|
||||
'User %s (telegram_id: %s) already has active subscription to channel %s via publication %s, '
|
||||
'ignoring new subscription attempt via publication %s',
|
||||
subscriber.id,
|
||||
user_telegram_id,
|
||||
placement.project_id,
|
||||
active_subscription.placement_id,
|
||||
placement.id,
|
||||
publication.placement.project_id,
|
||||
active_subscription.publication_id,
|
||||
publication.id,
|
||||
)
|
||||
return
|
||||
|
||||
# Проверяем, была ли раньше подписка через ЭТОТ placement (для реактивации)
|
||||
existing_sub = await self.database.get_subscription_by_subscriber_and_placement(subscriber.id, placement.id)
|
||||
# Проверяем, была ли раньше подписка через ЭТУ публикацию (для реактивации)
|
||||
existing_sub = await self.database.get_subscription_by_subscriber_and_publication(subscriber.id, publication.id)
|
||||
|
||||
if existing_sub and existing_sub.status == domain.SubscriptionStatus.UNSUBSCRIBED:
|
||||
existing_sub.status = domain.SubscriptionStatus.ACTIVE
|
||||
@@ -65,24 +69,24 @@ async def handle_subscription(
|
||||
await self.database.update_subscription(existing_sub)
|
||||
|
||||
log.info(
|
||||
'Subscription reactivated: subscriber %s (telegram_id: %s) resubscribed via same placement %s',
|
||||
'Subscription reactivated: subscriber %s (telegram_id: %s) resubscribed via same publication %s',
|
||||
subscriber.id,
|
||||
user_telegram_id,
|
||||
placement.id,
|
||||
publication.id,
|
||||
)
|
||||
return
|
||||
|
||||
subscription = domain.Subscription(
|
||||
placement_id=placement.id,
|
||||
publication_id=publication.id,
|
||||
telegram_user_id=subscriber.id,
|
||||
invite_link=invite_link,
|
||||
)
|
||||
await self.database.create_subscription(subscription)
|
||||
|
||||
log.info(
|
||||
'Subscription created: subscriber %s (telegram_id: %s) subscribed via placement %s (invite_link: %s)',
|
||||
'Subscription created: subscriber %s (telegram_id: %s) subscribed via publication %s (invite_link: %s)',
|
||||
subscriber.id,
|
||||
user_telegram_id,
|
||||
placement.id,
|
||||
publication.id,
|
||||
invite_link,
|
||||
)
|
||||
|
||||
@@ -39,8 +39,8 @@ async def handle_unsubscription(self: 'Usecase', user_telegram_id: int, channel_
|
||||
await self.database.update_subscription(subscription)
|
||||
|
||||
log.info(
|
||||
'Subscription marked as unsubscribed: subscriber %s (telegram_id: %s) unsubscribed from placement %s',
|
||||
'Subscription marked as unsubscribed: subscriber %s (telegram_id: %s) unsubscribed from publication %s',
|
||||
subscriber.id,
|
||||
user_telegram_id,
|
||||
subscription.placement_id,
|
||||
subscription.publication_id,
|
||||
)
|
||||
|
||||
@@ -14,18 +14,22 @@ async def get_views_history(self: 'Usecase', input: dto.GetViewsHistoryInput) ->
|
||||
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
|
||||
)
|
||||
|
||||
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)
|
||||
publication = await self.database.get_publication(input.workspace_id, input.publication_id)
|
||||
if not publication:
|
||||
log.warning('Publication %s not found for user %s', input.publication_id, input.user_id)
|
||||
raise domain.PublicationNotFound(input.publication_id)
|
||||
|
||||
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, placement.project_id)
|
||||
if not publication.placement:
|
||||
log.error('Publication %s has no placement', publication.id)
|
||||
raise domain.PlacementNotFound(publication.placement_id)
|
||||
|
||||
if not placement.post:
|
||||
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, publication.placement.project_id)
|
||||
|
||||
if not publication.post:
|
||||
return []
|
||||
|
||||
histories = await self.database.get_views_history(
|
||||
placement.post.id,
|
||||
publication.post.id,
|
||||
from_date=input.from_date,
|
||||
to_date=input.to_date,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user