diff --git a/Makefile b/Makefile index 5662db2..f9bad21 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,10 @@ test: pytest --no-header +build-parser: + docker build --platform linux/amd64 -f ./tg_parser/Dockerfile -t tg-parser:latest . + docker save tg-parser:latest -o tg-parser-x86.tar + env: cp .env.example .env diff --git a/migrations/models/4_20260120193804_update.py b/migrations/models/4_20260120193804_update.py new file mode 100644 index 0000000..f9b4f8a --- /dev/null +++ b/migrations/models/4_20260120193804_update.py @@ -0,0 +1,106 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX IF EXISTS "idx_placement_p_creativ_a8f431"; + DROP INDEX IF EXISTS "idx_placement_p_project_888982"; + ALTER TABLE "placement_post" DROP CONSTRAINT IF EXISTS "fk_placemen_project_f98a29a5"; + ALTER TABLE "placement_post" DROP CONSTRAINT IF EXISTS "fk_placemen_creative_db63ad95"; + ALTER TABLE "placement" ALTER COLUMN "creative_id" DROP NOT NULL; + ALTER TABLE "placement_post" DROP COLUMN "comment"; + ALTER TABLE "placement_post" DROP COLUMN "project_id"; + ALTER TABLE "placement_post" DROP COLUMN "cost"; + ALTER TABLE "placement_post" DROP COLUMN "wanted_placement_date"; + ALTER TABLE "placement_post" DROP COLUMN "creative_id";""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "placement" ALTER COLUMN "creative_id" SET NOT NULL; + ALTER TABLE "placement_post" ADD "comment" TEXT; + ALTER TABLE "placement_post" ADD "project_id" UUID NOT NULL; + ALTER TABLE "placement_post" ADD "cost" DOUBLE PRECISION; + ALTER TABLE "placement_post" ADD "wanted_placement_date" TIMESTAMPTZ NOT NULL; + ALTER TABLE "placement_post" ADD "creative_id" UUID NOT NULL; + ALTER TABLE "placement_post" ADD CONSTRAINT "fk_placemen_creative_db63ad95" FOREIGN KEY ("creative_id") REFERENCES "creative" ("id") ON DELETE CASCADE; + ALTER TABLE "placement_post" ADD CONSTRAINT "fk_placemen_project_f98a29a5" FOREIGN KEY ("project_id") REFERENCES "project" ("id") ON DELETE CASCADE; + CREATE INDEX IF NOT EXISTS "idx_placement_p_project_888982" ON "placement_post" ("project_id"); + CREATE INDEX IF NOT EXISTS "idx_placement_p_creativ_a8f431" ON "placement_post" ("creative_id");""" + + +MODELS_STATE = ( + "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" + "f/C4NtvdBk9VSAWNZJcSZj+9vP8f6Gvh+w==" +) diff --git a/migrations/models/5_20260120195442_update.py b/migrations/models/5_20260120195442_update.py new file mode 100644 index 0000000..af250b6 --- /dev/null +++ b/migrations/models/5_20260120195442_update.py @@ -0,0 +1,140 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "placement" ALTER COLUMN "status" SET DEFAULT 'Без статуса'; + UPDATE "placement" + SET "status" = 'Без статуса' + WHERE "status" IN ('planned', 'approved', 'in_progress', 'completed', 'rejected'); + ALTER TABLE "placement" ALTER COLUMN "status" TYPE VARCHAR(64) USING "status"::VARCHAR(64); + COMMENT ON COLUMN "placement"."status" IS 'NO_STATUS: Без статуса +WRITE: Написать +WAITING_RESPONSE: Ждём ответа +TERMS_APPROVAL: Согласование условий +TO_PAY: Оплатить +PAID: Оплачено +CANCELED: Отмена +PRICE_NOT_OK: Не подходит цена +NOT_RELEVANT: Неактуально +NO_RESPONSE: Не отвечает'; + ALTER TABLE "placement" ALTER COLUMN "invite_link" DROP NOT NULL; + ALTER TABLE "placement_post" DROP COLUMN IF EXISTS "wanted_placement_date"; + ALTER TABLE "placement_post" DROP COLUMN IF EXISTS "cost"; + ALTER TABLE "placement_post" DROP COLUMN IF EXISTS "comment"; + ALTER TABLE "placement_post" DROP COLUMN IF EXISTS "creative_id"; + ALTER TABLE "placement_post" DROP COLUMN IF EXISTS "project_id"; + ALTER TABLE "placement_post" DROP COLUMN IF EXISTS "status";""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "placement" ALTER COLUMN "status" SET DEFAULT 'planned'; + UPDATE "placement" + SET "status" = 'planned' + WHERE "status" IN ( + 'Без статуса', + 'Написать', + 'Ждём ответа', + 'Согласование условий', + 'Оплатить', + 'Оплачено', + 'Отмена', + 'Не подходит цена', + 'Неактуально', + 'Не отвечает' + ); + COMMENT ON COLUMN "placement"."status" IS 'PLANNED: planned +APPROVED: approved +REJECTED: rejected +IN_PROGRESS: in_progress +COMPLETED: completed'; + ALTER TABLE "placement" ALTER COLUMN "status" TYPE VARCHAR(11) USING "status"::VARCHAR(11); + ALTER TABLE "placement" ALTER COLUMN "invite_link" SET NOT NULL; + ALTER TABLE "placement_post" ADD "wanted_placement_date" TIMESTAMPTZ; + ALTER TABLE "placement_post" ADD "cost" DOUBLE PRECISION; + ALTER TABLE "placement_post" ADD "comment" TEXT; + ALTER TABLE "placement_post" ADD "creative_id" UUID; + ALTER TABLE "placement_post" ADD "project_id" UUID; + ALTER TABLE "placement_post" ADD "status" VARCHAR(8) NOT NULL DEFAULT 'active'; + COMMENT ON COLUMN "placement_post"."status" IS 'ACTIVE: active\nARCHIVED: archived';""" + + +MODELS_STATE = ( + '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' +) diff --git a/src/adapter/postgres.py b/src/adapter/postgres.py index 7270ce5..0b8ba17 100644 --- a/src/adapter/postgres.py +++ b/src/adapter/postgres.py @@ -382,6 +382,14 @@ class Postgres(DatabaseBase): 'project', 'project__channel', 'channel', 'creative' ) + @staticmethod + async def update_placement(placement: domain.Placement) -> None: + await placement.save() + + @staticmethod + async def delete_placement(placement_id: uuid.UUID) -> None: + await domain.Placement.filter(id=placement_id).delete() + @staticmethod async def get_project_placements( workspace_id: uuid.UUID, project_id: uuid.UUID, include_archived: bool = False @@ -391,10 +399,12 @@ class Postgres(DatabaseBase): if not include_archived: query = query.filter( status__in=[ - domain.PlacementStatus.PLANNED, - domain.PlacementStatus.APPROVED, - domain.PlacementStatus.IN_PROGRESS, - domain.PlacementStatus.COMPLETED, + domain.PlacementStatus.NO_STATUS, + domain.PlacementStatus.WRITE, + domain.PlacementStatus.WAITING_RESPONSE, + domain.PlacementStatus.TERMS_APPROVAL, + domain.PlacementStatus.TO_PAY, + domain.PlacementStatus.PAID, ] ) @@ -418,6 +428,10 @@ class Postgres(DatabaseBase): 'post__channel', ) + @staticmethod + async def count_placement_posts_by_placement(placement_id: uuid.UUID) -> int: + return await domain.PlacementPost.filter(placement_id=placement_id).count() + @staticmethod async def get_workspace_placement_posts( workspace_id: uuid.UUID, @@ -450,9 +464,6 @@ class Postgres(DatabaseBase): if date_to: query = query.filter(created_at__lte=date_to) - if not include_archived: - query = query.filter(status=domain.PlacementPostStatus.ACTIVE) - return ( await query.prefetch_related( 'placement', @@ -481,9 +492,6 @@ class Postgres(DatabaseBase): placement_id__in=placement_ids, ) - if not include_archived: - query = query.filter(status=domain.PlacementPostStatus.ACTIVE) - return ( await query.prefetch_related( 'placement', diff --git a/src/controller/http_v1/purchases.py b/src/controller/http_v1/purchases.py index 0bb554f..4d6ee6d 100644 --- a/src/controller/http_v1/purchases.py +++ b/src/controller/http_v1/purchases.py @@ -61,3 +61,51 @@ async def get_placement( placement_id=placement_id, ) return await deps.get_usecase().get_placement_user(input=input) + + +@placements_user_router.patch('/{placement_id}') +async def update_placement( + placement_id: uuid.UUID, + request: dto.UpdatePlacementInput, + workspace_id: uuid.UUID, + project_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.PlacementWithPostsOutput: + return await deps.get_usecase().update_placement( + placement_id=placement_id, + input=request, + workspace_id=workspace_id, + project_id=project_id, + user_id=current_user.user_id, + ) + + +@placements_user_router.post('/{placement_id}/creative') +async def build_placement_creative( + placement_id: uuid.UUID, + workspace_id: uuid.UUID, + project_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.CreativePreviewOutput: + return await deps.get_usecase().build_placement_creative( + placement_id=placement_id, + workspace_id=workspace_id, + project_id=project_id, + user_id=current_user.user_id, + ) + + +@placements_user_router.delete('/{placement_id}') +async def delete_placement( + placement_id: uuid.UUID, + workspace_id: uuid.UUID, + project_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> None: + input = dto.DeletePlacementInput( + user_id=current_user.user_id, + workspace_id=workspace_id, + project_id=project_id, + placement_id=placement_id, + ) + await deps.get_usecase().delete_placement(input=input) diff --git a/src/domain/__init__.py b/src/domain/__init__.py index 476c60f..ef68810 100644 --- a/src/domain/__init__.py +++ b/src/domain/__init__.py @@ -20,7 +20,6 @@ __all__ = ( 'PlacementStatus', 'PlacementType', 'PlacementPost', - 'PlacementPostStatus', 'Creative', 'replace_invite_link_with_tag', 'validate_media_size', @@ -34,7 +33,6 @@ __all__ = ( 'SubscriptionStatus', 'InviteLinkType', 'CostType', - 'PostViewsAvailability', 'LoginToken', 'UserNotFound', 'WorkspaceNotFound', @@ -104,7 +102,7 @@ from .placement import ( PlacementStatus, PlacementType, ) -from .placement_post import PlacementPost, PlacementPostStatus, PostViewsAvailability +from .placement_post import PlacementPost from .post import Post from .post_views_history import PostViewsHistory from .project import Project, ProjectStatus diff --git a/src/domain/error.py b/src/domain/error.py index 85eafad..a07fdec 100644 --- a/src/domain/error.py +++ b/src/domain/error.py @@ -59,6 +59,13 @@ def PlacementPostNotFound(placement_post_id: uuid.UUID | None = None) -> HTTPExc return HTTPException(status.HTTP_404_NOT_FOUND, f'PlacementPost {placement_post_id} not found') +def PlacementHasPosts(placement_id: uuid.UUID) -> HTTPException: + return HTTPException( + status.HTTP_400_BAD_REQUEST, + f'Placement {placement_id} has placement_posts and cannot remove creative', + ) + + def ChannelAlreadyExists(telegram_id: int) -> HTTPException: return HTTPException(status.HTTP_409_CONFLICT, f'Channel {telegram_id} already exists in the system') diff --git a/src/domain/placement.py b/src/domain/placement.py index 7f98394..a44adc1 100644 --- a/src/domain/placement.py +++ b/src/domain/placement.py @@ -20,11 +20,16 @@ class CostType(enum.StrEnum): class PlacementStatus(enum.StrEnum): - PLANNED = 'planned' - APPROVED = 'approved' - REJECTED = 'rejected' - IN_PROGRESS = 'in_progress' - COMPLETED = 'completed' + NO_STATUS = 'Без статуса' + WRITE = 'Написать' + WAITING_RESPONSE = 'Ждём ответа' + TERMS_APPROVAL = 'Согласование условий' + TO_PAY = 'Оплатить' + PAID = 'Оплачено' + CANCELED = 'Отмена' + PRICE_NOT_OK = 'Не подходит цена' + NOT_RELEVANT = 'Неактуально' + NO_RESPONSE = 'Не отвечает' class PlacementType(enum.StrEnum): @@ -38,7 +43,7 @@ class InviteLinkType(enum.StrEnum): class Placement(TimestampedModel): - status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.PLANNED) + status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.NO_STATUS) placement_at = fields.DatetimeField(null=True) payment_at = fields.DatetimeField(null=True) cost_type = fields.CharEnumField(CostType, null=True, max_length=8) @@ -48,14 +53,14 @@ class Placement(TimestampedModel): format = fields.TextField(null=True) comment = fields.TextField(null=True) - invite_link = fields.CharField(max_length=512) + invite_link = fields.CharField(max_length=512, null=True) invite_link_type = fields.CharEnumField(InviteLinkType) project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField( 'models.Project', related_name='placements', on_delete=fields.CASCADE, index=True ) creative: fields.ForeignKeyRelation['Creative'] = fields.ForeignKeyField( - 'models.Creative', related_name='placements', on_delete=fields.CASCADE, index=True + 'models.Creative', related_name='placements', on_delete=fields.CASCADE, null=True, index=True ) channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField( 'models.Channel', related_name='placements', on_delete=fields.CASCADE, index=True @@ -63,7 +68,7 @@ class Placement(TimestampedModel): if TYPE_CHECKING: project_id: uuid.UUID - creative_id: uuid.UUID + creative_id: uuid.UUID | None channel_id: uuid.UUID class Meta: diff --git a/src/domain/placement_post.py b/src/domain/placement_post.py index 9795773..3aad2de 100644 --- a/src/domain/placement_post.py +++ b/src/domain/placement_post.py @@ -1,4 +1,3 @@ -import enum from typing import TYPE_CHECKING from uuid import UUID @@ -7,50 +6,21 @@ from tortoise import fields from .base import TimestampedModel if TYPE_CHECKING: - from .creative import Creative from .placement import Placement from .post import Post - from .project import Project -__all__ = ['PlacementPost', 'PlacementPostStatus', 'PostViewsAvailability'] - - -class PlacementPostStatus(str, enum.Enum): - ACTIVE = 'active' # Пост найден и отслеживается - ARCHIVED = 'archived' # Вручную архивировано - - -class PostViewsAvailability(str, enum.Enum): - UNKNOWN = 'unknown' # Ещё не проверялось - AVAILABLE = 'available' # Просмотры доступны - UNAVAILABLE = 'unavailable' # Нет доступа / битая ссылка / приватный канал - MANUAL = 'manual' # Просмотры вводятся вручную +__all__ = ['PlacementPost'] class PlacementPost(TimestampedModel): - wanted_placement_date = fields.DatetimeField(db_column='placement_date') - cost = fields.FloatField(null=True) - comment = fields.TextField(null=True) - - status = fields.CharEnumField(PlacementPostStatus, default=PlacementPostStatus.ACTIVE) - - project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField( - 'models.Project', related_name='placement_posts', on_delete=fields.CASCADE, index=True - ) - creative: fields.ForeignKeyRelation['Creative'] = fields.ForeignKeyField( - 'models.Creative', related_name='placement_posts', on_delete=fields.CASCADE, index=True - ) placement: fields.ForeignKeyRelation['Placement'] = fields.ForeignKeyField( 'models.Placement', related_name='placement_posts', on_delete=fields.CASCADE, index=True ) - post: fields.ForeignKeyRelation['Post'] | None = fields.ForeignKeyField( 'models.Post', related_name='placement_posts', on_delete=fields.SET_NULL, null=True, index=True ) if TYPE_CHECKING: - project_id: UUID - creative_id: UUID post_id: UUID | None placement_id: UUID diff --git a/src/dto/__init__.py b/src/dto/__init__.py index 7519b8c..8c18ec6 100644 --- a/src/dto/__init__.py +++ b/src/dto/__init__.py @@ -25,10 +25,14 @@ __all__ = ( 'GetPlacementsInput', 'GetPlacementsOutput', 'GetPlacementInput', + 'UpdatePlacementInput', + 'DeletePlacementInput', 'PlacementPostOutput', + 'PostOutput', 'PlacementWithPostsOutput', 'CreativeButton', 'CreativeOutput', + 'CreativePreviewOutput', 'GetCreativesInput', 'GetCreativesOutput', 'GetCreativeInput', @@ -123,6 +127,7 @@ from .creative import ( CreateCreativeInput, CreativeButton, CreativeOutput, + CreativePreviewOutput, DeleteCreativeInput, GetCreativeInput, GetCreativesInput, @@ -146,13 +151,16 @@ from .purchase import ( CostInfo, CreatePlacementChannelInput, CreatePlacementsInput, + DeletePlacementInput, GetPlacementInput, GetPlacementsInput, GetPlacementsOutput, PlacementDetails, PlacementOutput, PlacementPostOutput, + PostOutput, PlacementWithPostsOutput, + UpdatePlacementInput, ) from .user import UserOutput from .validate_login_token import ValidateLoginTokenInput, ValidateLoginTokenOutput diff --git a/src/dto/creative.py b/src/dto/creative.py index 04aedb0..d9f3ee2 100644 --- a/src/dto/creative.py +++ b/src/dto/creative.py @@ -25,6 +25,15 @@ class CreativeOutput(pydantic.BaseModel): placements_count: int +class CreativePreviewOutput(pydantic.BaseModel): + id: uuid.UUID + name: str + text: str + media_type: str | None + media_file_id: str | None + buttons: list[CreativeButton] + + class GetCreativesInput(pydantic.BaseModel): user_id: uuid.UUID workspace_id: uuid.UUID diff --git a/src/dto/purchase.py b/src/dto/purchase.py index 3c84fc1..5d6e0c7 100644 --- a/src/dto/purchase.py +++ b/src/dto/purchase.py @@ -4,9 +4,9 @@ import uuid import pydantic from src.domain.placement import CostType, InviteLinkType, PlacementStatus, PlacementType -from src.domain.placement_post import PlacementPostStatus from .channel import ChannelOutput +from .creative import CreativePreviewOutput class CostInfo(pydantic.BaseModel): @@ -22,41 +22,41 @@ class PlacementDetails(pydantic.BaseModel): placement_type: PlacementType | None = None format: str | None = None comment: str | None = None + creative_id: uuid.UUID | None = None class PlacementOutput(pydantic.BaseModel): id: uuid.UUID status: PlacementStatus + creative_id: uuid.UUID | None = None comment: str | None = None - invite_link: str + invite_link: str | None invite_link_type: InviteLinkType channel: ChannelOutput details: PlacementDetails | None = None -class PlacementPostOutput(pydantic.BaseModel): +class PostOutput(pydantic.BaseModel): id: uuid.UUID - project_id: uuid.UUID - project_channel_title: str | None - placement_channel_id: uuid.UUID # Канал, где размещен пост - placement_channel_title: str | None - creative_id: uuid.UUID - creative_name: str - placement_date: datetime.datetime # wanted_placement_date - cost: float | None - comment: str | None - ad_post_url: str | None - invite_link_type: InviteLinkType - invite_link: str - status: PlacementPostStatus - subscriptions_count: int - placement_id: uuid.UUID # Ссылка на Placement - placement: PlacementOutput + message_id: int + text: str + url: str | None + deleted_from_channel_at: datetime.datetime | None created_at: datetime.datetime + updated_at: datetime.datetime + + +class PlacementPostOutput(pydantic.BaseModel): + subscriptions_count: int + views_count: int | None + created_at: datetime.datetime + post: PostOutput class PlacementWithPostsOutput(PlacementOutput): - placement_posts: list[PlacementPostOutput] = pydantic.Field(default_factory=list) + placement_post: PlacementPostOutput | None = None + + class CreatePlacementChannelInput(pydantic.BaseModel): @@ -71,7 +71,7 @@ class CreatePlacementChannelInput(pydantic.BaseModel): class CreatePlacementsInput(pydantic.BaseModel): """Input для создания нескольких placements (бывший CreatePurchaseInput)""" - creative_id: uuid.UUID + creative_id: uuid.UUID | None = None channels: list[CreatePlacementChannelInput] details: PlacementDetails | None = None # Общие детали для всех placements @@ -91,3 +91,22 @@ class GetPlacementInput(pydantic.BaseModel): workspace_id: uuid.UUID project_id: uuid.UUID placement_id: uuid.UUID + + +class UpdatePlacementInput(pydantic.BaseModel): + status: PlacementStatus | None = None + comment: str | None = None + creative_id: uuid.UUID | None = None + placement_at: datetime.datetime | None = None + payment_at: datetime.datetime | None = None + cost: CostInfo | None = None + cost_before_bargain: float | None = None + placement_type: PlacementType | None = None + format: str | None = None + + +class DeletePlacementInput(pydantic.BaseModel): + user_id: uuid.UUID + workspace_id: uuid.UUID + project_id: uuid.UUID + placement_id: uuid.UUID diff --git a/src/usecase/__init__.py b/src/usecase/__init__.py index e68733f..f9cf486 100644 --- a/src/usecase/__init__.py +++ b/src/usecase/__init__.py @@ -38,9 +38,12 @@ from .project.move_project_to_workspace import move_project_to_workspace from .project.tg_add_project import tg_add_project from .project.update_project_invite_link_type import update_project_invite_link_type from .project.update_project_permissions import update_project_permissions +from .purchase.build_placement_creative import build_placement_creative from .purchase.create_placements import create_placements +from .purchase.delete_placement import delete_placement from .purchase.get_placement import get_placement_user from .purchase.get_placements import get_placements +from .purchase.update_placement import update_placement from .subscription.handle_subscription import handle_subscription from .subscription.handle_unsubscription import handle_unsubscription from .views.get_views_history import get_views_history @@ -167,6 +170,9 @@ class Usecase: create_placements = create_placements get_placements = get_placements get_placement_user = get_placement_user + build_placement_creative = build_placement_creative + update_placement = update_placement + delete_placement = delete_placement # Creative use cases get_creatives = get_creatives get_creative = get_creative diff --git a/src/usecase/analytics/get_channel_analytics.py b/src/usecase/analytics/get_channel_analytics.py index b4f8503..e5598d3 100644 --- a/src/usecase/analytics/get_channel_analytics.py +++ b/src/usecase/analytics/get_channel_analytics.py @@ -11,6 +11,11 @@ if TYPE_CHECKING: log = logging.getLogger(__name__) +def _get_cost(placement_post: domain.PlacementPost) -> float: + placement = placement_post.placement + return placement.cost_value if placement and placement.cost_value is not None else 0.0 + + async def get_channel_analytics( self: 'Usecase', input: dto.GetChannelAnalyticsInput ) -> list[dto.ChannelAnalyticsOutput]: @@ -69,7 +74,7 @@ async def get_channel_analytics( if stats is None: continue - stats.total_cost += placement_post.cost if placement_post.cost is not None else 0 + stats.total_cost += _get_cost(placement_post) stats.total_subscriptions += subscriptions_counts.get(placement_post.id, 0) # Get views from batch data diff --git a/src/usecase/analytics/get_creatives_analytics.py b/src/usecase/analytics/get_creatives_analytics.py index 04f11da..a295371 100644 --- a/src/usecase/analytics/get_creatives_analytics.py +++ b/src/usecase/analytics/get_creatives_analytics.py @@ -11,6 +11,11 @@ if TYPE_CHECKING: log = logging.getLogger(__name__) +def _get_cost(placement_post: domain.PlacementPost) -> float: + placement = placement_post.placement + return placement.cost_value if placement and placement.cost_value is not None else 0.0 + + async def get_creatives_analytics( self: 'Usecase', input: dto.GetCreativesAnalyticsInput ) -> list[dto.CreativeAnalyticsOutput]: @@ -57,17 +62,20 @@ async def get_creatives_analytics( placement_ids = [p.id for p in placements] subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_ids) - for p in placements: - stats = creative_stats.get(p.creative_id) + for placement_post in placements: + placement = placement_post.placement + if not placement or not placement.creative_id: + continue + stats = creative_stats.get(placement.creative_id) if stats is None: continue - stats.total_cost += p.cost if p.cost is not None else 0 - stats.total_subscriptions += subscriptions_counts.get(p.id, 0) + stats.total_cost += _get_cost(placement_post) + stats.total_subscriptions += subscriptions_counts.get(placement_post.id, 0) # Get views from batch data - if p.post and p.post.id in views_map: - views_count = views_map[p.post.id][0] + if placement_post.post and placement_post.post.id in views_map: + views_count = views_map[placement_post.post.id][0] stats.total_views += views_count stats.placements_count += 1 diff --git a/src/usecase/analytics/get_overview_analytics.py b/src/usecase/analytics/get_overview_analytics.py index 8b456d0..93e36af 100644 --- a/src/usecase/analytics/get_overview_analytics.py +++ b/src/usecase/analytics/get_overview_analytics.py @@ -29,6 +29,20 @@ class ChannelAggregate: total_subs: int = 0 +def _get_placement_date(placement_post: domain.PlacementPost) -> datetime.datetime: + placement = placement_post.placement + if placement and placement.placement_at: + return placement.placement_at + if placement_post.post and placement_post.post.created_at: + return placement_post.post.created_at + return placement_post.created_at + + +def _get_cost(placement_post: domain.PlacementPost) -> float: + placement = placement_post.placement + return placement.cost_value if placement and placement.cost_value is not None else 0.0 + + async def get_overview_analytics( self: 'Usecase', input: dto.GetOverviewAnalyticsInput ) -> dto.GetOverviewAnalyticsOutput: @@ -68,7 +82,7 @@ async def get_overview_analytics( previous_placements: list[domain.PlacementPost] = [] for placement_post in placements: - placement_date = placement_post.wanted_placement_date + placement_date = _get_placement_date(placement_post) if placement_date >= input.date_from and placement_date <= input.date_to: current_placements.append(placement_post) elif placement_date >= previous_period_start and placement_date < input.date_from: @@ -105,8 +119,7 @@ async def get_overview_analytics( total_views = 0 for placement_post in placement_list: - if placement_post.cost is not None: - total_cost += placement_post.cost + total_cost += _get_cost(placement_post) subs = subs_per_placement.get(placement_post.id, 0) total_subscriptions += subs @@ -131,22 +144,23 @@ async def get_overview_analytics( project_meta: dict[UUID, domain.Project] = {} for placement_post in current_placements: - placement_day = placement_post.wanted_placement_date.date() + placement_day = _get_placement_date(placement_post).date() placement = placement_post.placement - if placement and placement.project: - project_meta[placement_post.project_id] = placement.project + if not placement: + continue + if placement.project: + project_meta[placement.project_id] = placement.project - cost_value = placement_post.cost or 0.0 + cost_value = _get_cost(placement_post) cost_per_day[placement_day] += cost_value - project_spending_map[placement_post.project_id] += cost_value + project_spending_map[placement.project_id] += cost_value subs = subs_per_placement_current.get(placement_post.id, 0) - if placement: - channel_id = placement.channel_id - if channel_id not in channel_stats: - channel_stats[channel_id] = ChannelAggregate(channel=placement.channel) - channel_stats[channel_id].total_cost += cost_value - channel_stats[channel_id].total_subs += subs + channel_id = placement.channel_id + if channel_id not in channel_stats: + channel_stats[channel_id] = ChannelAggregate(channel=placement.channel) + channel_stats[channel_id].total_cost += cost_value + channel_stats[channel_id].total_subs += subs start_date = input.date_from.date() end_date = input.date_to.date() diff --git a/src/usecase/analytics/get_placements_analytics.py b/src/usecase/analytics/get_placements_analytics.py index 6dea642..f0fd97f 100644 --- a/src/usecase/analytics/get_placements_analytics.py +++ b/src/usecase/analytics/get_placements_analytics.py @@ -1,3 +1,4 @@ +import datetime import logging from typing import TYPE_CHECKING @@ -9,6 +10,20 @@ if TYPE_CHECKING: log = logging.getLogger(__name__) +def _get_placement_date(placement_post: domain.PlacementPost) -> datetime.datetime: + placement = placement_post.placement + if placement and placement.placement_at: + return placement.placement_at + if placement_post.post and placement_post.post.created_at: + return placement_post.post.created_at + return placement_post.created_at + + +def _get_cost(placement_post: domain.PlacementPost) -> float | None: + placement = placement_post.placement + return placement.cost_value if placement else None + + def _calculate_cpf(cost: float | None, subscriptions: int) -> float | None: if cost is None or subscriptions == 0: return None @@ -52,21 +67,36 @@ async def get_placements_analytics( placement_ids = [p.id for p in placements] subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_ids) - return [ - dto.PlacementAnalyticsOutput( - id=p.id, - project_id=p.project_id, - project_channel_title=p.project.channel.title, - placement_channel_id=p.placement.channel_id, - placement_channel_title=p.placement.channel.title, - creative_id=p.creative_id, - creative_name=p.creative.name, - placement_date=p.wanted_placement_date, - cost=p.cost, - subscriptions_count=subscriptions_counts.get(p.id, 0), - views_count=views_map[p.post.id][0] if p.post and p.post.id in views_map else None, - cpf=_calculate_cpf(p.cost, subscriptions_counts.get(p.id, 0)), - cpm=_calculate_cpm(p.cost, views_map[p.post.id][0] if p.post and p.post.id in views_map else None), + results: list[dto.PlacementAnalyticsOutput] = [] + for placement_post in placements: + placement = placement_post.placement + if not placement or not placement.project or not placement.channel or not placement.creative: + log.warning('PlacementPost %s missing placement data', placement_post.id) + continue + if not placement.project.channel: + log.warning('Placement %s missing project channel', placement.id) + continue + cost = _get_cost(placement_post) + subs_count = subscriptions_counts.get(placement_post.id, 0) + if placement_post.post and placement_post.post.id in views_map: + views_count = views_map[placement_post.post.id][0] + else: + views_count = None + results.append( + dto.PlacementAnalyticsOutput( + id=placement_post.id, + project_id=placement.project_id, + project_channel_title=placement.project.channel.title, + placement_channel_id=placement.channel_id, + placement_channel_title=placement.channel.title, + creative_id=placement.creative_id, + creative_name=placement.creative.name, + placement_date=_get_placement_date(placement_post), + cost=cost, + subscriptions_count=subs_count, + views_count=views_count, + cpf=_calculate_cpf(cost, subs_count), + cpm=_calculate_cpm(cost, views_count), + ) ) - for p in placements - ] + return results diff --git a/src/usecase/analytics/get_projects_analytics.py b/src/usecase/analytics/get_projects_analytics.py index 6ca68d4..690583f 100644 --- a/src/usecase/analytics/get_projects_analytics.py +++ b/src/usecase/analytics/get_projects_analytics.py @@ -60,20 +60,29 @@ def _format_period_label(dt: datetime.datetime, grouping: dto.DateGrouping) -> s raise ValueError('Invalid date grouping') +def _get_placement_date(placement_post: domain.PlacementPost) -> datetime.datetime: + placement = placement_post.placement + if placement and placement.placement_at: + return placement.placement_at + if placement_post.post and placement_post.post.created_at: + return placement_post.post.created_at + return placement_post.created_at + + def _get_grouping_date(placement_post: domain.PlacementPost, date_grouping: dto.DateGroupingType) -> datetime.datetime: match date_grouping: case dto.DateGroupingType.PLACEMENT_DATE: - return placement_post.wanted_placement_date + return _get_placement_date(placement_post) case dto.DateGroupingType.PURCHASE_DATE: if placement_post.placement: return placement_post.placement.created_at - return placement_post.wanted_placement_date # Fallback + return _get_placement_date(placement_post) # Fallback case dto.DateGroupingType.LINK_DATE: if placement_post.placement: return placement_post.placement.created_at - return placement_post.wanted_placement_date # Fallback + return _get_placement_date(placement_post) # Fallback case _: - return placement_post.wanted_placement_date + return _get_placement_date(placement_post) @dataclass @@ -223,7 +232,8 @@ async def get_projects_analytics( pd.purchases_count += 1 total_metrics.purchases_count += 1 - cost = placement_post.cost or 0.0 + placement = placement_post.placement + cost = placement.cost_value if placement and placement.cost_value is not None else 0.0 pd.total_cost += cost total_metrics.total_cost += cost @@ -241,7 +251,6 @@ async def get_projects_analytics( total_metrics.reach_volume += views_count # Расчет скидок - placement = placement_post.placement if placement and placement.cost_before_bargain and placement.cost_before_bargain > cost: discount = placement.cost_before_bargain - cost discount_percent = ( diff --git a/src/usecase/analytics/get_spending_analytics.py b/src/usecase/analytics/get_spending_analytics.py index fb2195c..4f8da83 100644 --- a/src/usecase/analytics/get_spending_analytics.py +++ b/src/usecase/analytics/get_spending_analytics.py @@ -30,6 +30,20 @@ def _format_period(dt: 'datetime.datetime', grouping: dto.DateGrouping) -> str: raise ValueError('Invalid date grouping') +def _get_placement_date(placement_post: domain.PlacementPost) -> datetime.datetime: + placement = placement_post.placement + if placement and placement.placement_at: + return placement.placement_at + if placement_post.post and placement_post.post.created_at: + return placement_post.post.created_at + return placement_post.created_at + + +def _get_cost(placement_post: domain.PlacementPost) -> float | None: + placement = placement_post.placement + return placement.cost_value if placement else None + + async def get_spending_analytics( self: 'Usecase', input: dto.GetSpendingAnalyticsInput ) -> dto.GetSpendingAnalyticsOutput: @@ -53,12 +67,14 @@ async def get_spending_analytics( allowed_project_ids=allowed_project_ids, ) - filtered = [ - p - for p in placements - if (not input.date_from or p.wanted_placement_date >= input.date_from) - and (not input.date_to or p.wanted_placement_date <= input.date_to) - ] + filtered: list[tuple[domain.PlacementPost, datetime.datetime]] = [] + for placement_post in placements: + placement_date = _get_placement_date(placement_post) + if input.date_from and placement_date < input.date_from: + continue + if input.date_to and placement_date > input.date_to: + continue + filtered.append((placement_post, placement_date)) total_cost = 0.0 total_subs = 0 @@ -71,23 +87,24 @@ async def get_spending_analytics( views: int = 0 # Batch fetch views data for all posts - post_ids = [p.post.id for p in filtered if p.post] + post_ids = [p.post.id for p, _ in filtered if p.post] views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {} # Batch fetch subscriptions counts - placement_ids = [p.id for p in filtered] + placement_ids = [p.id for p, _ in filtered] subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_ids) # Группировка по периодам period_data: dict[str, PeriodData] = defaultdict(PeriodData) - for p in filtered: - period = _format_period(p.wanted_placement_date, input.grouping) + for p, placement_date in filtered: + period = _format_period(placement_date, input.grouping) pd = period_data[period] - if p.cost is not None: - total_cost += p.cost - pd.cost += p.cost + cost = _get_cost(p) + if cost is not None: + total_cost += cost + pd.cost += cost subs_count = subscriptions_counts.get(p.id, 0) total_subs += subs_count diff --git a/src/usecase/placement/fetch_placement_post_cycle.py b/src/usecase/placement/fetch_placement_post_cycle.py index d0fa2d6..1b2e43a 100644 --- a/src/usecase/placement/fetch_placement_post_cycle.py +++ b/src/usecase/placement/fetch_placement_post_cycle.py @@ -17,9 +17,12 @@ async def fetch_placement_post_cycle(self: 'Usecase', interval_seconds: int) -> placements = ( await domain.Placement.filter( status__in=[ - domain.PlacementStatus.APPROVED, - domain.PlacementStatus.PLANNED, - domain.PlacementStatus.IN_PROGRESS, + domain.PlacementStatus.NO_STATUS, + domain.PlacementStatus.WRITE, + domain.PlacementStatus.WAITING_RESPONSE, + domain.PlacementStatus.TERMS_APPROVAL, + domain.PlacementStatus.TO_PAY, + domain.PlacementStatus.PAID, ] ) .prefetch_related('channel', 'project') @@ -36,6 +39,13 @@ async def fetch_placement_post_cycle(self: 'Usecase', interval_seconds: int) -> if placement.channel is None or placement.invite_link is None: log.warning('Placement %s missing channel or invite_link, skipping', placement.id) continue + if placement.creative_id is None: + log.warning('Placement %s missing creative_id, skipping', placement.id) + continue + existing_for_placement = await domain.PlacementPost.filter(placement_id=placement.id).first() + if existing_for_placement: + log.debug('Placement %s already has placement_post %s, skipping', placement.id, existing_for_placement.id) + continue # Ищем посты в канале, содержащие invite_link из placement posts = await domain.Post.filter( @@ -52,13 +62,7 @@ async def fetch_placement_post_cycle(self: 'Usecase', interval_seconds: int) -> placement_post = domain.PlacementPost( placement_id=placement.id, - project_id=placement.project_id, - creative_id=placement.creative_id, post_id=post.id, - wanted_placement_date=placement.placement_at or post.created_at, - cost=placement.cost_value, - comment=placement.comment, - status=domain.PlacementPostStatus.ACTIVE, ) await self.database.create_placement_post(placement_post) diff --git a/src/usecase/purchase/build_placement_creative.py b/src/usecase/purchase/build_placement_creative.py new file mode 100644 index 0000000..d8c58a7 --- /dev/null +++ b/src/usecase/purchase/build_placement_creative.py @@ -0,0 +1,92 @@ +import logging +import re +import uuid +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + +_INVITE_LINK_TAG = re.compile(r'([^<]*)', re.IGNORECASE) +_INVITE_LINK_PLACEHOLDER = '{{invite_link}}' + + +def _inject_invite_link(text: str, invite_link: str) -> str: + if not text: + return text + + def _replace(match: re.Match) -> str: + inner = match.group(1).strip() + if inner: + return f'{inner}' + return f'{invite_link}' + + return _INVITE_LINK_TAG.sub(_replace, text) + + +def _build_buttons(buttons: list[dict], invite_link: str) -> list[dto.CreativeButton]: + result: list[dto.CreativeButton] = [] + for raw in buttons: + text = raw.get('text') + url = raw.get('url') + if not text or not url: + continue + if url == _INVITE_LINK_PLACEHOLDER: + url = invite_link + result.append(dto.CreativeButton(text=text, url=url)) + return result + + +async def build_placement_creative( + self: 'Usecase', + placement_id: uuid.UUID, + workspace_id: uuid.UUID, + project_id: uuid.UUID, + user_id: uuid.UUID, +) -> dto.CreativePreviewOutput: + context = await self.ensure_workspace_permission( + workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE + ) + + project = await self.database.get_project(workspace_id, project_id=project_id) + if not project: + raise domain.ProjectNotFound(project_id) + + context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id) + + placement = await self.database.get_placement(workspace_id, placement_id) + if not placement or placement.project_id != project.id: + raise domain.PlacementNotFound(placement_id) + + if placement.creative_id is None: + raise domain.CreativeNotFound() + + creative = await self.database.get_creative(workspace_id, placement.creative_id) + if not creative or creative.project_id != project.id: + raise domain.CreativeNotFound(placement.creative_id) + + if not placement.invite_link: + if project.channel.telegram_id is None: + raise domain.ChannelNotFound(project.channel.id) + + requires_approval = placement.invite_link_type == domain.InviteLinkType.APPROVAL + invite_link = await self.telegram_bot.create_chat_invite_link(project.channel.telegram_id, requires_approval) + placement.invite_link = invite_link + await self.database.update_placement(placement) + + invite_link = placement.invite_link + if not invite_link: + raise domain.PlacementNotFound(placement.id) + + preview = dto.CreativePreviewOutput( + id=creative.id, + name=creative.name, + text=_inject_invite_link(creative.text, invite_link), + media_type=creative.media_type, + media_file_id=creative.media_file_id, + buttons=_build_buttons(creative.buttons, invite_link), + ) + return preview diff --git a/src/usecase/purchase/create_placements.py b/src/usecase/purchase/create_placements.py index 6c4e089..8413380 100644 --- a/src/usecase/purchase/create_placements.py +++ b/src/usecase/purchase/create_placements.py @@ -40,6 +40,7 @@ def _build_placement_output(placement: domain.Placement) -> dto.PlacementOutput: return dto.PlacementOutput( id=placement.id, status=placement.status, + creative_id=placement.creative_id, comment=placement.comment, invite_link=placement.invite_link, invite_link_type=placement.invite_link_type, @@ -69,18 +70,13 @@ async def create_placements( context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id) - creative = await self.database.get_creative(workspace_id, input.creative_id) - if not creative or creative.project_id != project.id: - raise domain.CreativeNotFound(input.creative_id) - if project.channel.telegram_id is None: raise domain.ChannelNotFound(project.channel.id) invite_link_type = project.purchase_invite_type_default - requires_approval = invite_link_type == domain.InviteLinkType.APPROVAL - details = input.details placements: list[domain.Placement] = [] + creatives_by_id: dict[uuid.UUID, domain.Creative] = {} for channel_input in input.channels: channel = await self.database.get_channel(username=channel_input.username) @@ -98,23 +94,26 @@ async def create_placements( await self.database.create_channel(channel) log.info('Created channel @%s with telegram_id=%s', channel.username, channel.telegram_id) - invite_link = await self.telegram_bot.create_chat_invite_link(project.channel.telegram_id, requires_approval) - log.info( - 'Created invite link for placement channel_telegram_id=%s requires_approval=%s invite_link=%s', - project.channel.telegram_id, - requires_approval, - invite_link, - ) - # Объединяем общие детали с деталями конкретного канала channel_details = channel_input.details + creative_id = ( + channel_details.creative_id if channel_details and channel_details.creative_id else None + ) or (details.creative_id if details and details.creative_id else None) or input.creative_id + if not creative_id: + raise domain.CreativeNotFound() + creative = creatives_by_id.get(creative_id) + if not creative: + creative = await self.database.get_creative(workspace_id, creative_id) + if not creative or creative.project_id != project.id: + raise domain.CreativeNotFound(creative_id) + creatives_by_id[creative_id] = creative placement = domain.Placement( project_id=project.id, creative_id=creative.id, channel_id=channel.id, - invite_link=invite_link, + invite_link=None, invite_link_type=invite_link_type, - status=channel_input.status or domain.PlacementStatus.PLANNED, + status=channel_input.status or domain.PlacementStatus.NO_STATUS, comment=channel_input.comment, # Приоритет: channel details > общие details placement_at=(channel_details.placement_at if channel_details else None) @@ -135,10 +134,10 @@ async def create_placements( placements.append(placement) log.info( - 'Created %s placements for project %s (creative %s)', + 'Created %s placements for project %s (creatives %s)', len(placements), project.id, - creative.id, + list(creatives_by_id.keys()), ) placement_outputs = [] @@ -147,7 +146,7 @@ async def create_placements( placement_outputs.append( dto.PlacementWithPostsOutput( **placement_output.model_dump(), - placement_posts=[], + placement_post=None, ) ) diff --git a/src/usecase/purchase/delete_placement.py b/src/usecase/purchase/delete_placement.py new file mode 100644 index 0000000..bdee320 --- /dev/null +++ b/src/usecase/purchase/delete_placement.py @@ -0,0 +1,28 @@ +import logging +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def delete_placement(self: 'Usecase', input: dto.DeletePlacementInput) -> None: + context = await self.ensure_workspace_permission( + input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_WRITE + ) + + project = await self.database.get_project(input.workspace_id, project_id=input.project_id) + if not project: + raise domain.ProjectNotFound(input.project_id) + + context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id) + + placement = await self.database.get_placement(input.workspace_id, input.placement_id) + if not placement or placement.project_id != project.id: + log.warning('User %s attempted to delete unavailable placement %s', input.user_id, input.placement_id) + raise domain.PlacementNotFound(input.placement_id) + + await self.database.delete_placement(placement.id) diff --git a/src/usecase/purchase/get_placement.py b/src/usecase/purchase/get_placement.py index adb6894..a5a3a32 100644 --- a/src/usecase/purchase/get_placement.py +++ b/src/usecase/purchase/get_placement.py @@ -7,39 +7,40 @@ from src.usecase.purchase.create_placements import _build_placement_output log = logging.getLogger(__name__) +def _build_post_output(post: domain.Post) -> dto.PostOutput: + channel = post.channel + if not channel: + raise ValueError(f'Post {post.id} has no channel') + + return dto.PostOutput( + id=post.id, + message_id=post.message_id, + text=post.text, + url=post.url, + deleted_from_channel_at=post.deleted_from_channel_at, + created_at=post.created_at, + updated_at=post.updated_at, + ) + + def _build_placement_post_output( - placement_post: domain.PlacementPost, subscriptions_count: int + placement_post: domain.PlacementPost, subscriptions_count: int, views_count: int | None ) -> dto.PlacementPostOutput | None: - placement = placement_post.placement - if not placement: - log.warning('PlacementPost %s missing placement data', placement_post.id) + if not placement_post.post: + log.warning('PlacementPost %s missing post', placement_post.id) return None - if not placement.channel or not placement.project or not placement.creative: - log.warning('PlacementPost %s placement missing related data', placement_post.id) + try: + post_output = _build_post_output(placement_post.post) + except ValueError: + log.warning('PlacementPost %s post missing channel data', placement_post.id) return None - ad_post_url = placement_post.post.url if placement_post.post else None - return dto.PlacementPostOutput( - id=placement_post.id, - project_id=placement.project_id, - project_channel_title=placement.project.channel.title if placement.project.channel else None, - placement_channel_id=placement.channel_id, - placement_channel_title=placement.channel.title, - creative_id=placement.creative_id, - creative_name=placement.creative.name, - placement_date=placement.placement_at or placement_post.created_at, - cost=placement.cost_value, - comment=placement.comment, - ad_post_url=ad_post_url, - invite_link_type=placement.invite_link_type, - invite_link=placement.invite_link, - status=placement_post.status, subscriptions_count=subscriptions_count, - placement_id=placement.id, - placement=_build_placement_output(placement), + views_count=views_count, created_at=placement_post.created_at, + post=post_output, ) @@ -75,17 +76,27 @@ async def get_placement_user(self: 'Usecase', input: dto.GetPlacementInput) -> d ) placement_post_ids = [post.id for post in placement_posts] subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_post_ids) + post_ids = [placement_post.post.id for placement_post in placement_posts if placement_post.post] + views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {} - placement_post_outputs = [] - for placement_post in placement_posts: - placement_post_output = _build_placement_post_output( - placement_post, subscriptions_counts.get(placement_post.id, 0) - ) - if placement_post_output is not None: - placement_post_outputs.append(placement_post_output) + placement_post_output = None + if placement_posts: + if len(placement_posts) > 1: + log.warning('Placement %s has %s placement_posts, returning latest', placement.id, len(placement_posts)) + for placement_post in placement_posts: + views_count = None + if placement_post.post: + views_count = views_map.get(placement_post.post.id, (None,))[0] + placement_post_output = _build_placement_post_output( + placement_post, + subscriptions_counts.get(placement_post.id, 0), + views_count, + ) + if placement_post_output is not None: + break placement_output = _build_placement_output(placement) return dto.PlacementWithPostsOutput( **placement_output.model_dump(), - placement_posts=placement_post_outputs, + placement_post=placement_post_output, ) diff --git a/src/usecase/purchase/get_placements.py b/src/usecase/purchase/get_placements.py index bfa0cf1..b75603a 100644 --- a/src/usecase/purchase/get_placements.py +++ b/src/usecase/purchase/get_placements.py @@ -36,11 +36,18 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto. ) placement_post_ids = [post.id for post in placement_posts] subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_post_ids) + post_ids = [placement_post.post.id for placement_post in placement_posts if placement_post.post] + views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {} placement_posts_by_placement_id: dict[uuid.UUID, list[dto.PlacementPostOutput]] = {} for placement_post in placement_posts: + views_count = None + if placement_post.post: + views_count = views_map.get(placement_post.post.id, (None,))[0] placement_post_output = _build_placement_post_output( - placement_post, subscriptions_counts.get(placement_post.id, 0) + placement_post, + subscriptions_counts.get(placement_post.id, 0), + views_count, ) if placement_post_output is None: continue @@ -49,10 +56,18 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto. placement_outputs = [] for placement in placements: placement_output = _build_placement_output(placement) + placement_post_output = None + placement_posts_for_placement = placement_posts_by_placement_id.get(placement.id, []) + if placement_posts_for_placement: + if len(placement_posts_for_placement) > 1: + log.warning( + 'Placement %s has %s placement_posts, returning latest', placement.id, len(placement_posts_for_placement) + ) + placement_post_output = placement_posts_for_placement[0] placement_outputs.append( dto.PlacementWithPostsOutput( **placement_output.model_dump(), - placement_posts=placement_posts_by_placement_id.get(placement.id, []), + placement_post=placement_post_output, ) ) diff --git a/src/usecase/purchase/update_placement.py b/src/usecase/purchase/update_placement.py new file mode 100644 index 0000000..8c2f593 --- /dev/null +++ b/src/usecase/purchase/update_placement.py @@ -0,0 +1,80 @@ +import logging +import uuid +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def update_placement( + self: 'Usecase', + placement_id: uuid.UUID, + input: dto.UpdatePlacementInput, + workspace_id: uuid.UUID, + project_id: uuid.UUID, + user_id: uuid.UUID, +) -> dto.PlacementWithPostsOutput: + context = await self.ensure_workspace_permission( + workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE + ) + + project = await self.database.get_project(workspace_id, project_id=project_id) + if not project: + raise domain.ProjectNotFound(project_id) + + context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id) + + placement = await self.database.get_placement(workspace_id, placement_id) + if not placement or placement.project_id != project.id: + raise domain.PlacementNotFound(placement_id) + + fields_set = input.model_fields_set + + if 'creative_id' in fields_set: + if input.creative_id is None: + placement_posts_count = await self.database.count_placement_posts_by_placement(placement.id) + if placement_posts_count > 0: + log.warning('Placement %s has placement_posts and cannot remove creative', placement.id) + raise domain.PlacementHasPosts(placement.id) + placement.creative_id = None + else: + creative = await self.database.get_creative(workspace_id, input.creative_id) + if not creative or creative.project_id != project.id: + raise domain.CreativeNotFound(input.creative_id) + placement.creative_id = creative.id + + if 'status' in fields_set and input.status is not None: + placement.status = input.status + if 'comment' in fields_set: + placement.comment = input.comment + if 'placement_at' in fields_set: + placement.placement_at = input.placement_at + if 'payment_at' in fields_set: + placement.payment_at = input.payment_at + if 'cost' in fields_set: + if input.cost is None: + placement.cost_type = None + placement.cost_value = None + else: + placement.cost_type = input.cost.type + placement.cost_value = input.cost.value + if 'cost_before_bargain' in fields_set: + placement.cost_before_bargain = input.cost_before_bargain + if 'placement_type' in fields_set: + placement.placement_type = input.placement_type + if 'format' in fields_set: + placement.format = input.format + + await self.database.update_placement(placement) + + placement_input = dto.GetPlacementInput( + user_id=user_id, + workspace_id=workspace_id, + project_id=project_id, + placement_id=placement.id, + ) + return await self.get_placement_user(input=placement_input) diff --git a/tg_bot/backend/backend_client.go b/tg_bot/backend/backend_client.go index 512b926..5b17ba7 100644 --- a/tg_bot/backend/backend_client.go +++ b/tg_bot/backend/backend_client.go @@ -555,27 +555,36 @@ type PlacementDetails struct { PlacementType *string `json:"placement_type,omitempty"` Format *string `json:"format,omitempty"` Comment *string `json:"comment,omitempty"` + CreativeID *string `json:"creative_id,omitempty"` } type PlacementOutput struct { - ID string `json:"id"` - Status string `json:"status"` - Comment *string `json:"comment,omitempty"` - InviteLink string `json:"invite_link"` - InviteLinkType string `json:"invite_link_type"` - Channel Channel `json:"channel"` - Details *PlacementDetails `json:"details,omitempty"` - PlacementPosts []PlacementPostOutput `json:"placement_posts,omitempty"` + ID string `json:"id"` + Status string `json:"status"` + CreativeID *string `json:"creative_id,omitempty"` + Comment *string `json:"comment,omitempty"` + InviteLink *string `json:"invite_link,omitempty"` + InviteLinkType string `json:"invite_link_type"` + Channel Channel `json:"channel"` + Details *PlacementDetails `json:"details,omitempty"` + PlacementPost *PlacementPostOutput `json:"placement_post,omitempty"` } type PlacementPostOutput struct { - ID string `json:"id"` - PlacementID string `json:"placement_id"` - Placement PlacementOutput `json:"placement"` - AdPostURL *string `json:"ad_post_url,omitempty"` - Status string `json:"status"` - SubscriptionsCount int `json:"subscriptions_count"` - CreatedAt string `json:"created_at"` + SubscriptionsCount int `json:"subscriptions_count"` + ViewsCount *int `json:"views_count,omitempty"` + CreatedAt string `json:"created_at"` + Post PostOutput `json:"post"` +} + +type PostOutput struct { + ID string `json:"id"` + MessageID int `json:"message_id"` + Text string `json:"text"` + URL *string `json:"url,omitempty"` + DeletedFromChannelAt *string `json:"deleted_from_channel_at,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } type CreatePlacementChannelInput struct { @@ -586,7 +595,7 @@ type CreatePlacementChannelInput struct { } type CreatePlacementsInput struct { - CreativeID string `json:"creative_id"` + CreativeID *string `json:"creative_id,omitempty"` Channels []CreatePlacementChannelInput `json:"channels"` Details *PlacementDetails `json:"details,omitempty"` } @@ -595,6 +604,15 @@ type GetPlacementsOutput struct { Placements []PlacementOutput `json:"placements"` } +type CreativePreviewOutput struct { + ID string `json:"id"` + Name string `json:"name"` + Text string `json:"text"` + MediaType string `json:"media_type"` + MediaFileID string `json:"media_file_id"` + Buttons []CreativeButton `json:"buttons"` +} + func (c *Client) CreatePlacements( ctx context.Context, jwt string, @@ -661,6 +679,33 @@ func (c *Client) GetPlacement( return &placement, err } +func (c *Client) BuildPlacementCreative( + ctx context.Context, + jwt string, + workspaceID string, + projectID string, + placementID string, +) (*CreativePreviewOutput, error) { + path := fmt.Sprintf( + "api/v1/workspaces/%s/projects/%s/placements/%s/creative", + workspaceID, + projectID, + placementID, + ) + + var resp CreativePreviewOutput + err := c.do( + ctx, + http.MethodPost, + path, + nil, + &resp, + withBearer(jwt), + ) + + return &resp, err +} + // ============================================================================ // Workspace Members // ============================================================================ diff --git a/tg_bot/bot/bot.go b/tg_bot/bot/bot.go index 2a048b5..ef0fbb5 100644 --- a/tg_bot/bot/bot.go +++ b/tg_bot/bot/bot.go @@ -329,13 +329,16 @@ func (b *Bot) Update(u *echotron.Update) { // 1. Команды (приоритетный маршрут) if u.Message != nil { - switch u.Message.Text { - case "/start": + switch { + case u.Message.Text == "/start": b.SetState(b.commandRouter("/start"), NewMessage) return - case "/start login": + case u.Message.Text == "/start login": b.SetState(b.commandRouter("/start login"), NewMessage) return + case strings.HasPrefix(u.Message.Text, "/start project_"): + b.SetState(b.commandRouter(u.Message.Text), NewMessage) + return } b.CurrentState.HandleMessage(b, u) diff --git a/tg_bot/main.go b/tg_bot/main.go index 77c7700..90b71f1 100644 --- a/tg_bot/main.go +++ b/tg_bot/main.go @@ -2,6 +2,7 @@ package main import ( "os" + "regexp" "strings" "time" @@ -24,11 +25,19 @@ func main() { }) var commandRouter = func(command string) bot.State { - switch command { - case "/start": + createCreativeRe := regexp.MustCompile(`^/start project_(.+)_createcreative$`) + + switch { + case command == "/start": return &screens.MainMenu{} - case "/start login": + case command == "/start login": return &screens.Login{} + case createCreativeRe.MatchString(command): + projectID := createCreativeRe.FindStringSubmatch(command)[1] + return &screens.AddCreativeStart{Ctx: &screens.AddCreativeCtx{ + ProjectID: projectID, + BackState: &screens.MainMenu{}, + }} } panic("Unknown command: " + command) } diff --git a/tg_bot/screens/add_creative.go b/tg_bot/screens/add_creative.go index 18b3c4b..7443666 100644 --- a/tg_bot/screens/add_creative.go +++ b/tg_bot/screens/add_creative.go @@ -21,7 +21,7 @@ type AddCreativeCtx struct { BackState bot.State } -type AddCreativeStart struct{ ctx *AddCreativeCtx } +type AddCreativeStart struct{ Ctx *AddCreativeCtx } type AddCreativeEdit struct{ ctx *AddCreativeCtx } @@ -102,8 +102,8 @@ func (s *AddCreativeStart) HandleCallback(b *bot.Bot, u *echotron.Update) { if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { return } - if u.CallbackQuery.Data == "cancel" && s.ctx.BackState != nil { - b.SetState(s.ctx.BackState, bot.EditMessage) + if u.CallbackQuery.Data == "cancel" && s.Ctx.BackState != nil { + b.SetState(s.Ctx.BackState, bot.EditMessage) } } @@ -112,14 +112,14 @@ func (s *AddCreativeStart) HandleMessage(b *bot.Bot, u *echotron.Update) { return } - s.ctx.extractCreativeFromMessage(u.Message) - if s.ctx.Name == nil { - name := s.ctx.generateCreativeName() - s.ctx.Name = &name + s.Ctx.extractCreativeFromMessage(u.Message) + if s.Ctx.Name == nil { + name := s.Ctx.generateCreativeName() + s.Ctx.Name = &name } - s.ctx.DeleteUserMessage(b, u.Message.ID) - b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.NewMessage) + s.Ctx.DeleteUserMessage(b, u.Message.ID) + b.SetState(&AddCreativeEdit{ctx: s.Ctx}, bot.NewMessage) } func (s *AddCreativeStart) Handle(_ *bot.Bot, _ *echotron.Update) {} diff --git a/tg_bot/screens/creatives.go b/tg_bot/screens/creatives.go index 4b59286..099d3c2 100644 --- a/tg_bot/screens/creatives.go +++ b/tg_bot/screens/creatives.go @@ -109,7 +109,7 @@ func (s *Creatives) HandleCallback(b *bot.Bot, u *echotron.Update) { b.Edit("📦 Архив креативов\n\nЭта функция в разработке", Keyboard(Row(Button("← Назад", "back")))) case data == "add_creative": - b.SetState(&AddCreativeStart{ctx: &AddCreativeCtx{ + b.SetState(&AddCreativeStart{Ctx: &AddCreativeCtx{ ProjectID: s.ProjectID, BackState: s, }}, bot.EditMessage) diff --git a/tg_bot/screens/placement_details.go b/tg_bot/screens/placement_details.go index 94b9679..126635f 100644 --- a/tg_bot/screens/placement_details.go +++ b/tg_bot/screens/placement_details.go @@ -28,10 +28,7 @@ func (s *PlacementDetails) renderDetails(b *bot.Bot, mode bot.RenderMode) { return } - var placementPost *backend.PlacementPostOutput - if len(placement.PlacementPosts) > 0 { - placementPost = &placement.PlacementPosts[0] - } + placementPost := placement.PlacementPost text := "Детали размещения\n\n" @@ -69,12 +66,11 @@ func (s *PlacementDetails) renderDetails(b *bot.Bot, mode bot.RenderMode) { if placementPost != nil { text += "\nПост\n" - if placementPost.AdPostURL != nil && *placementPost.AdPostURL != "" { - text += fmt.Sprintf("• Ссылка на пост: %s\n", *placementPost.AdPostURL) + if placementPost.Post.URL != nil && *placementPost.Post.URL != "" { + text += fmt.Sprintf("• Ссылка на пост: %s\n", *placementPost.Post.URL) } else { text += "• Ссылка на пост: не найдена\n" } - text += fmt.Sprintf("• Статус поста: %s\n", formatPlacementPostStatus(placementPost.Status)) } keyboard := Keyboard(Row(Button("← Назад", "back"))) @@ -128,18 +124,6 @@ func formatPlacementType(value string) string { } } -func formatPlacementPostStatus(status string) string { - normalized := strings.TrimSpace(strings.ToLower(status)) - switch normalized { - case "active": - return "Активен" - case "archived": - return "Архивирован" - default: - return status - } -} - func formatCostType(value string) string { switch strings.TrimSpace(strings.ToLower(value)) { case "cpm": diff --git a/tg_bot/screens/purchase_optional_details.go b/tg_bot/screens/purchase_optional_details.go index 5e2a3f8..f42a56d 100644 --- a/tg_bot/screens/purchase_optional_details.go +++ b/tg_bot/screens/purchase_optional_details.go @@ -3,7 +3,6 @@ package screens import ( "context" "fmt" - "regexp" "strconv" "strings" "time" @@ -1425,7 +1424,7 @@ func (s *PurchaseOptionalDetails) createPurchase(b *bot.Bot, jwt string) { } input := backend.CreatePlacementsInput{ - CreativeID: s.CreativeID, + CreativeID: &s.CreativeID, Channels: apiChannels, Details: placementDetails, } @@ -1439,20 +1438,24 @@ func (s *PurchaseOptionalDetails) createPurchase(b *bot.Bot, jwt string) { return } - creative, err := b.Backend.GetCreative(context.Background(), jwt, b.Session.WorkspaceID, s.CreativeID) - if err != nil { - log.Error().Err(err).Msg("Failed to get creative") - creative = nil - } - - if creative != nil { - for _, placement := range placements.Placements { - channelName := s.formatChannelName(placement) - header := fmt.Sprintf("Канал: %s", channelName) - b.SendMessage(header, b.ChatID, &echotron.MessageOptions{ParseMode: echotron.HTML}) - - s.sendCreativeWithInviteLink(b, creative, placement.InviteLink) + for _, placement := range placements.Placements { + creativePreview, err := b.Backend.BuildPlacementCreative( + context.Background(), + jwt, + b.Session.WorkspaceID, + s.ProjectID, + placement.ID, + ) + if err != nil { + log.Error().Err(err).Msg("Failed to build placement creative") + continue } + + channelName := s.formatChannelName(placement) + header := fmt.Sprintf("Канал: %s", channelName) + b.SendMessage(header, b.ChatID, &echotron.MessageOptions{ParseMode: echotron.HTML}) + + s.sendCreativePreview(b, creativePreview) } b.SetState(&Placements{ @@ -1588,15 +1591,12 @@ func (s *PurchaseOptionalDetails) formatChannelName(placement backend.PlacementO return "Без названия" } -func (s *PurchaseOptionalDetails) sendCreativeWithInviteLink( +func (s *PurchaseOptionalDetails) sendCreativePreview( b *bot.Bot, - creative *backend.Creative, - inviteLink string, + creative *backend.CreativePreviewOutput, ) { - log.Info().Str("invite_link", inviteLink).Msg("Sending creative with invite link") - linkedText := s.injectInviteLink(creative.Text, inviteLink) editor := CreativeEditorFields{ - Text: &linkedText, + Text: &creative.Text, MediaType: creative.MediaType, MediaFileID: creative.MediaFileID, } @@ -1604,34 +1604,12 @@ func (s *PurchaseOptionalDetails) sendCreativeWithInviteLink( if len(creative.Buttons) > 0 { editor.Buttons = make([]InlineButton, 0, len(creative.Buttons)) for _, btn := range creative.Buttons { - url := btn.URL - if url == inviteLinkPlaceholder { - url = inviteLink - } editor.Buttons = append(editor.Buttons, InlineButton{ Text: btn.Text, - URL: url, + URL: btn.URL, }) } } editor.SendCreativePreview(b) } - -func (s *PurchaseOptionalDetails) injectInviteLink(text, inviteLink string) string { - if text == "" { - return text - } - re := regexp.MustCompile(`([^<]*)`) - return re.ReplaceAllStringFunc(text, func(match string) string { - sub := re.FindStringSubmatch(match) - if len(sub) < 2 { - return match - } - inner := strings.TrimSpace(sub[1]) - if inner == "" { - return fmt.Sprintf(`%s`, inviteLink, inviteLink) - } - return fmt.Sprintf(`%s`, inviteLink, sub[1]) - }) -} diff --git a/tg_bot/screens/select_channels_for_purchase.go b/tg_bot/screens/select_channels_for_purchase.go index c38db33..1cce617 100644 --- a/tg_bot/screens/select_channels_for_purchase.go +++ b/tg_bot/screens/select_channels_for_purchase.go @@ -249,7 +249,7 @@ func (s *SelectChannelsForPurchase) createPurchase(b *bot.Bot, jwt string) { } input := backend.CreatePlacementsInput{ - CreativeID: s.CreativeID, + CreativeID: &s.CreativeID, Channels: apiChannels, } diff --git a/tg_parser/Dockerfile b/tg_parser/Dockerfile index eec949b..2f5e100 100644 --- a/tg_parser/Dockerfile +++ b/tg_parser/Dockerfile @@ -9,7 +9,7 @@ RUN go mod download # Build layer COPY tg_parser /app/tg_parser -RUN CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o /parser . +RUN CGO_ENABLED=0 GOOS=linux go build -o /parser . FROM alpine:latest AS run