From 87643735a130cfbcfd78d6c7340c580810202706 Mon Sep 17 00:00:00 2001 From: Artem Tsyrulnikov Date: Tue, 6 Jan 2026 19:25:01 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B7=D0=B0=D0=BA=D1=83=D0=BF=D1=8B=20=D0=B2?= =?UTF-8?q?=20=D0=B1=D1=8D=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- migrations/models/4_20260106192017_update.py | 149 ++++++++++++++++++ src/adapter/postgres.py | 18 ++- src/domain/purchase.py | 30 +++- src/dto/__init__.py | 6 + src/dto/purchase.py | 31 +++- src/usecase/__init__.py | 6 +- src/usecase/purchase/create_purchase.py | 58 ++++++- .../adapter/backend/backend_client.go | 46 ++++-- telegram_bot/screens/purchase_details.go | 4 +- .../screens/select_channels_for_purchase.go | 16 +- 10 files changed, 334 insertions(+), 30 deletions(-) create mode 100644 migrations/models/4_20260106192017_update.py diff --git a/migrations/models/4_20260106192017_update.py b/migrations/models/4_20260106192017_update.py new file mode 100644 index 0000000..dba4085 --- /dev/null +++ b/migrations/models/4_20260106192017_update.py @@ -0,0 +1,149 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "purchase" + ADD "comment" TEXT; + ALTER TABLE "purchase" + ADD "payment_at" TIMESTAMPTZ; + ALTER TABLE "purchase" + ADD "format" TEXT; + ALTER TABLE "purchase" + ADD "cost_before_bargain" DOUBLE PRECISION; + ALTER TABLE "purchase" + ADD "placement_at" TIMESTAMPTZ; + ALTER TABLE "purchase" + ADD "cost_type" VARCHAR(8); + ALTER TABLE "purchase" + ADD "cost_value" DOUBLE PRECISION; + ALTER TABLE "purchase" + ADD "purchase_type" VARCHAR(16); + ALTER TABLE "purchase_channel" + ADD "format" TEXT; + ALTER TABLE "purchase_channel" + ADD "cost_before_bargain" DOUBLE PRECISION; + ALTER TABLE "purchase_channel" + ADD "placement_at" TIMESTAMPTZ; + ALTER TABLE "purchase_channel" + ADD "cost_type" VARCHAR(8); + ALTER TABLE "purchase_channel" + ADD "cost_value" DOUBLE PRECISION; + ALTER TABLE "purchase_channel" + DROP COLUMN "planned_cost"; + COMMENT ON COLUMN "purchase"."cost_type" IS 'FIXED: fixed\nCPM: cpm'; + COMMENT ON COLUMN "purchase"."purchase_type" IS 'SELF_PROMO: self_promo\nSTANDARD: standard'; + COMMENT ON COLUMN "purchase_channel"."cost_type" IS 'FIXED: fixed\nCPM: cpm';""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "purchase" + DROP COLUMN "comment"; + ALTER TABLE "purchase" + DROP COLUMN "payment_at"; + ALTER TABLE "purchase" + DROP COLUMN "format"; + ALTER TABLE "purchase" + DROP COLUMN "cost_before_bargain"; + ALTER TABLE "purchase" + DROP COLUMN "placement_at"; + ALTER TABLE "purchase" + DROP COLUMN "cost_type"; + ALTER TABLE "purchase" + DROP COLUMN "cost_value"; + ALTER TABLE "purchase" + DROP COLUMN "purchase_type"; + ALTER TABLE "purchase_channel" + ADD "planned_cost" DOUBLE PRECISION; + ALTER TABLE "purchase_channel" + DROP COLUMN "format"; + ALTER TABLE "purchase_channel" + DROP COLUMN "cost_before_bargain"; + ALTER TABLE "purchase_channel" + DROP COLUMN "placement_at"; + ALTER TABLE "purchase_channel" + DROP COLUMN "cost_type"; + ALTER TABLE "purchase_channel" + DROP COLUMN "cost_value";""" + + +MODELS_STATE = ( + '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' + '49ruDVq8qUI6aKVL2pn/5+X3/wGuY8FA' +) diff --git a/src/adapter/postgres.py b/src/adapter/postgres.py index fb1761e..45dc097 100644 --- a/src/adapter/postgres.py +++ b/src/adapter/postgres.py @@ -304,8 +304,12 @@ class Postgres(DatabaseBase, Database): invite_link_type: domain.purchase.InviteLinkType, *, status: domain.PurchaseChannelStatus | None = None, - planned_cost: float | 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, @@ -313,10 +317,18 @@ class Postgres(DatabaseBase, Database): } if status is not None: defaults['status'] = status - if planned_cost is not None: - defaults['planned_cost'] = planned_cost 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, diff --git a/src/domain/purchase.py b/src/domain/purchase.py index 4c77bc0..8bba5c0 100644 --- a/src/domain/purchase.py +++ b/src/domain/purchase.py @@ -13,12 +13,17 @@ if TYPE_CHECKING: from .workspace import Workspace -class PurchaseStatus(str, enum.Enum): +class PurchaseStatus(enum.StrEnum): ACTIVE = 'active' ARCHIVED = 'archived' -class PurchaseChannelStatus(str, enum.Enum): +class CostType(enum.StrEnum): + FIXED = 'fixed' + CPM = 'cpm' + + +class PurchaseChannelStatus(enum.StrEnum): PLANNED = 'planned' APPROVED = 'approved' REJECTED = 'rejected' @@ -26,8 +31,21 @@ class PurchaseChannelStatus(str, enum.Enum): COMPLETED = 'completed' +class PurchaseType(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 @@ -45,14 +63,18 @@ class Purchase(TimestampedModel): indexes = (('project', 'status'),) -class InviteLinkType(str, enum.Enum): +class InviteLinkType(enum.StrEnum): PUBLIC = 'public' # открытая ссылка APPROVAL = 'approval' # с одобрением ботом class PurchaseChannel(TimestampedModel): status = fields.CharEnumField(PurchaseChannelStatus, default=PurchaseChannelStatus.PLANNED) - planned_cost = fields.FloatField(null=True) + placement_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) + format = fields.TextField(null=True) comment = fields.TextField(null=True) invite_link = fields.CharField(max_length=512) diff --git a/src/dto/__init__.py b/src/dto/__init__.py index b6805dd..2135372 100644 --- a/src/dto/__init__.py +++ b/src/dto/__init__.py @@ -17,6 +17,9 @@ __all__ = ( 'AttachChannelToWorkspaceInput', 'PurchaseOutput', 'PurchaseChannelOutput', + 'PurchaseDetails', + 'PurchaseChannelDetails', + 'CostInfo', 'CreatePurchaseInput', 'CreatePurchaseChannelInput', 'GetPurchasesInput', @@ -117,12 +120,15 @@ from .project import ( UpdateProjectPermissionsInput, ) from .purchase import ( + CostInfo, CreatePurchaseChannelInput, CreatePurchaseInput, GetPurchaseInput, GetPurchasesInput, GetPurchasesOutput, + PurchaseChannelDetails, PurchaseChannelOutput, + PurchaseDetails, PurchaseOutput, ) from .user import UserOutput diff --git a/src/dto/purchase.py b/src/dto/purchase.py index 4299c1a..7081aa5 100644 --- a/src/dto/purchase.py +++ b/src/dto/purchase.py @@ -1,20 +1,43 @@ +import datetime import uuid import pydantic -from src.domain.purchase import InviteLinkType, PurchaseChannelStatus, PurchaseStatus +from src.domain.purchase import CostType, InviteLinkType, PurchaseChannelStatus, PurchaseStatus, PurchaseType from .channel import ChannelOutput +class CostInfo(pydantic.BaseModel): + type: CostType + value: float + + +class PurchaseDetails(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 + 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): id: uuid.UUID status: PurchaseChannelStatus - planned_cost: float | None = None comment: str | None = None invite_link: str invite_link_type: InviteLinkType channel: ChannelOutput + details: PurchaseChannelDetails | None = None class PurchaseOutput(pydantic.BaseModel): @@ -22,18 +45,20 @@ class PurchaseOutput(pydantic.BaseModel): status: PurchaseStatus creative_id: uuid.UUID channels: list[PurchaseChannelOutput] + details: PurchaseDetails | None = None class CreatePurchaseChannelInput(pydantic.BaseModel): username: str = pydantic.Field(pattern=r'^[^@]') status: PurchaseChannelStatus | None = None - planned_cost: float | None = None comment: str | None = None + details: PurchaseChannelDetails | None = None class CreatePurchaseInput(pydantic.BaseModel): creative_id: uuid.UUID channels: list[CreatePurchaseChannelInput] + details: PurchaseDetails | None = None class GetPurchasesInput(pydantic.BaseModel): diff --git a/src/usecase/__init__.py b/src/usecase/__init__.py index c5b3f10..75b0faa 100644 --- a/src/usecase/__init__.py +++ b/src/usecase/__init__.py @@ -174,8 +174,12 @@ class Database(typing.Protocol): invite_link_type: domain.purchase.InviteLinkType, *, status: domain.PurchaseChannelStatus | None = None, - planned_cost: float | 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: ... diff --git a/src/usecase/purchase/create_purchase.py b/src/usecase/purchase/create_purchase.py index 0793c50..45501f7 100644 --- a/src/usecase/purchase/create_purchase.py +++ b/src/usecase/purchase/create_purchase.py @@ -11,6 +11,39 @@ if TYPE_CHECKING: 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: @@ -24,7 +57,6 @@ def _build_purchase_output( dto.PurchaseChannelOutput( id=purchase_channel.id, status=purchase_channel.status, - planned_cost=purchase_channel.planned_cost, comment=purchase_channel.comment, invite_link=purchase_channel.invite_link, invite_link_type=purchase_channel.invite_link_type, @@ -34,6 +66,7 @@ def _build_purchase_output( title=channel.title, username=channel.username, ), + details=_build_purchase_channel_details(purchase_channel), ) ) @@ -42,6 +75,7 @@ def _build_purchase_output( status=purchase.status, creative_id=purchase.creative_id, channels=channels_output, + details=_build_purchase_details(purchase), ) @@ -69,7 +103,20 @@ async def create_purchase( channels = await self.database.get_purchase_channels(existing_purchase.id) return _build_purchase_output(existing_purchase, channels) - purchase = domain.Purchase(workspace_id=workspace_id, project_id=project.id, creative_id=creative.id) + details = input.details + purchase = domain.Purchase( + workspace_id=workspace_id, + project_id=project.id, + creative_id=creative.id, + placement_at=details.placement_at if details else None, + 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: @@ -94,14 +141,19 @@ async def create_purchase( invite_link = await self.telegram_bot.create_chat_invite_link(project.channel.telegram_id, requires_approval) + 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, - planned_cost=channel_input.planned_cost, 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) diff --git a/telegram_bot/adapter/backend/backend_client.go b/telegram_bot/adapter/backend/backend_client.go index 40dffc2..cf17fa7 100644 --- a/telegram_bot/adapter/backend/backend_client.go +++ b/telegram_bot/adapter/backend/backend_client.go @@ -632,28 +632,52 @@ type Purchase struct { Status string `json:"status"` CreativeID string `json:"creative_id"` Channels []PurchaseChannelOut `json:"channels"` + Details *PurchaseDetails `json:"details,omitempty"` } type PurchaseChannelOut struct { - ID string `json:"id"` - Status string `json:"status"` - PlannedCost *float64 `json:"planned_cost"` - Comment *string `json:"comment"` - InviteLink string `json:"invite_link"` - InviteLinkType string `json:"invite_link_type"` - Channel Channel `json:"channel"` + ID string `json:"id"` + Status string `json:"status"` + Comment *string `json:"comment"` + InviteLink string `json:"invite_link"` + InviteLinkType string `json:"invite_link_type"` + Channel Channel `json:"channel"` + Details *PurchaseChannelDetails `json:"details,omitempty"` +} + +type CostInfo struct { + Type string `json:"type"` + Value float64 `json:"value"` +} + +type PurchaseDetails struct { + PlacementAt *string `json:"placement_at,omitempty"` + PaymentAt *string `json:"payment_at,omitempty"` + Cost *CostInfo `json:"cost,omitempty"` + CostBeforeBargain *float64 `json:"cost_before_bargain,omitempty"` + PurchaseType *string `json:"purchase_type,omitempty"` + Format *string `json:"format,omitempty"` + Comment *string `json:"comment,omitempty"` +} + +type PurchaseChannelDetails struct { + PlacementAt *string `json:"placement_at,omitempty"` + Cost *CostInfo `json:"cost,omitempty"` + CostBeforeBargain *float64 `json:"cost_before_bargain,omitempty"` + Format *string `json:"format,omitempty"` } type CreatePurchaseChannelInput struct { - Username string `json:"username"` - Status *string `json:"status,omitempty"` - PlannedCost *float64 `json:"planned_cost,omitempty"` - Comment *string `json:"comment,omitempty"` + Username string `json:"username"` + Status *string `json:"status,omitempty"` + Comment *string `json:"comment,omitempty"` + Details *PurchaseChannelDetails `json:"details,omitempty"` } type CreatePurchaseInput struct { CreativeID string `json:"creative_id"` Channels []CreatePurchaseChannelInput `json:"channels"` + Details *PurchaseDetails `json:"details,omitempty"` } func (c *Client) CreatePurchase( diff --git a/telegram_bot/screens/purchase_details.go b/telegram_bot/screens/purchase_details.go index 3186f5a..fcb6034 100644 --- a/telegram_bot/screens/purchase_details.go +++ b/telegram_bot/screens/purchase_details.go @@ -105,8 +105,8 @@ func (s *PurchaseDetails) renderDetails(b *bot.Bot, mode bot.RenderMode, jwt str text += fmt.Sprintf("%d. %s %s\n", i+1, statusSymbol, channelName) // Стоимость - if ch.PlannedCost != nil { - text += fmt.Sprintf(" ▸ Стоимость: %.0f₽\n", *ch.PlannedCost) + if ch.Details != nil && ch.Details.Cost != nil { + text += fmt.Sprintf(" ▸ Стоимость: %.0f₽\n", ch.Details.Cost.Value) } // Комментарий diff --git a/telegram_bot/screens/select_channels_for_purchase.go b/telegram_bot/screens/select_channels_for_purchase.go index cb58908..a0cb39e 100644 --- a/telegram_bot/screens/select_channels_for_purchase.go +++ b/telegram_bot/screens/select_channels_for_purchase.go @@ -221,10 +221,20 @@ func (s *SelectChannelsForPurchase) createPurchase(b *bot.Bot, jwt string) { // Преобразуем каналы в формат API var apiChannels []backend.CreatePurchaseChannelInput for _, ch := range s.Channels { + var details *backend.PurchaseChannelDetails + if ch.PlannedCost != nil { + cost := backend.CostInfo{ + Type: "fixed", + Value: *ch.PlannedCost, + } + details = &backend.PurchaseChannelDetails{ + Cost: &cost, + } + } apiChannels = append(apiChannels, backend.CreatePurchaseChannelInput{ - Username: ch.Username, - PlannedCost: ch.PlannedCost, - Comment: ch.Comment, + Username: ch.Username, + Comment: ch.Comment, + Details: details, }) }