diff --git a/src/adapter/postgres.py b/src/adapter/postgres.py index 06482d2..3737dd8 100644 --- a/src/adapter/postgres.py +++ b/src/adapter/postgres.py @@ -418,6 +418,7 @@ class Postgres(DatabaseBase): project_id: uuid.UUID | None = None, placement_channel_id: uuid.UUID | None = None, creative_id: uuid.UUID | None = None, + placement_id: uuid.UUID | None = None, include_archived: bool = False, allowed_project_ids: set[uuid.UUID] | None = None, date_from: datetime.datetime | None = None, @@ -435,6 +436,8 @@ class Postgres(DatabaseBase): query = query.filter(placement__channel_id=placement_channel_id) if creative_id: query = query.filter(placement__creative_id=creative_id) + if placement_id: + query = query.filter(placement_id=placement_id) if date_from: query = query.filter(created_at__gte=date_from) diff --git a/src/controller/http_v1/placements.py b/src/controller/http_v1/placements.py index 7a5e68c..0c3b854 100644 --- a/src/controller/http_v1/placements.py +++ b/src/controller/http_v1/placements.py @@ -18,6 +18,7 @@ async def list_placement_posts( project_id: uuid.UUID | None = None, placement_channel_id: uuid.UUID | None = None, creative_id: uuid.UUID | None = None, + placement_id: uuid.UUID | None = None, include_archived: bool = False, ) -> dto.GetPlacementPostsOutput: """List all placement_posts (system-managed actual posts) in workspace""" @@ -27,6 +28,7 @@ async def list_placement_posts( project_id=project_id, placement_channel_id=placement_channel_id, creative_id=creative_id, + placement_id=placement_id, include_archived=include_archived, ) diff --git a/src/dto/placement.py b/src/dto/placement.py index cfbfe37..37f8d6f 100644 --- a/src/dto/placement.py +++ b/src/dto/placement.py @@ -4,6 +4,7 @@ import uuid import pydantic from src import domain +from src.dto.purchase import PlacementOutput class PlacementPostOutput(pydantic.BaseModel): @@ -23,6 +24,7 @@ class PlacementPostOutput(pydantic.BaseModel): status: domain.PlacementPostStatus subscriptions_count: int placement_id: uuid.UUID # Ссылка на Placement + placement: PlacementOutput created_at: datetime.datetime @@ -32,6 +34,7 @@ class GetPlacementPostsInput(pydantic.BaseModel): project_id: uuid.UUID | None = None placement_channel_id: uuid.UUID | None = None creative_id: uuid.UUID | None = None + placement_id: uuid.UUID | None = None include_archived: bool = False diff --git a/src/usecase/placement/fetch_placement_post_cycle.py b/src/usecase/placement/fetch_placement_post_cycle.py index 34d9e04..d0fa2d6 100644 --- a/src/usecase/placement/fetch_placement_post_cycle.py +++ b/src/usecase/placement/fetch_placement_post_cycle.py @@ -15,7 +15,13 @@ async def fetch_placement_post_cycle(self: 'Usecase', interval_seconds: int) -> # Получаем все approved Placements с invite_link placements = ( - await domain.Placement.filter(status__in=[domain.PlacementStatus.APPROVED, domain.PlacementStatus.IN_PROGRESS]) + await domain.Placement.filter( + status__in=[ + domain.PlacementStatus.APPROVED, + domain.PlacementStatus.PLANNED, + domain.PlacementStatus.IN_PROGRESS, + ] + ) .prefetch_related('channel', 'project') .all() ) diff --git a/src/usecase/placement/get_placement_post.py b/src/usecase/placement/get_placement_post.py index 46915bc..c0691ba 100644 --- a/src/usecase/placement/get_placement_post.py +++ b/src/usecase/placement/get_placement_post.py @@ -2,6 +2,7 @@ import logging from typing import TYPE_CHECKING from src import domain, dto +from src.usecase.purchase.create_placements import _build_placement_output if TYPE_CHECKING: from .. import Usecase @@ -51,5 +52,6 @@ async def get_placement_post(self: 'Usecase', input: dto.GetPlacementPostInput) status=placement_post.status, subscriptions_count=subscriptions_count, placement_id=placement.id, + placement=_build_placement_output(placement), created_at=placement_post.created_at, ) diff --git a/src/usecase/placement/get_placement_posts.py b/src/usecase/placement/get_placement_posts.py index ff06c74..375be21 100644 --- a/src/usecase/placement/get_placement_posts.py +++ b/src/usecase/placement/get_placement_posts.py @@ -2,6 +2,7 @@ import logging from typing import TYPE_CHECKING from src import domain, dto +from src.usecase.purchase.create_placements import _build_placement_output if TYPE_CHECKING: from .. import Usecase @@ -26,6 +27,7 @@ async def get_placement_posts(self: 'Usecase', input: dto.GetPlacementPostsInput project_id=input.project_id, placement_channel_id=input.placement_channel_id, creative_id=input.creative_id, + placement_id=input.placement_id, include_archived=input.include_archived, allowed_project_ids=allowed_project_ids, ) @@ -64,6 +66,7 @@ async def get_placement_posts(self: 'Usecase', input: dto.GetPlacementPostsInput status=placement_post.status, subscriptions_count=subscriptions_counts.get(placement_post.id, 0), placement_id=placement.id, + placement=_build_placement_output(placement), created_at=placement_post.created_at, ) ) diff --git a/tg_bot/backend/backend_client.go b/tg_bot/backend/backend_client.go index 89be910..966b526 100644 --- a/tg_bot/backend/backend_client.go +++ b/tg_bot/backend/backend_client.go @@ -567,6 +567,16 @@ type PlacementOutput struct { Details *PlacementDetails `json:"details,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"` +} + type CreatePlacementChannelInput struct { Username string `json:"username"` Status *string `json:"status,omitempty"` @@ -584,6 +594,10 @@ type GetPlacementsOutput struct { Placements []PlacementOutput `json:"placements"` } +type GetPlacementPostsOutput struct { + PlacementPosts []PlacementPostOutput `json:"placement_posts"` +} + func (c *Client) CreatePlacements( ctx context.Context, jwt string, @@ -650,6 +664,39 @@ func (c *Client) GetPlacement( return &placement, err } +func (c *Client) GetPlacementPosts( + ctx context.Context, + jwt string, + workspaceID string, + projectID *string, + placementID *string, +) (*GetPlacementPostsOutput, error) { + q := url.Values{} + if projectID != nil && *projectID != "" { + q.Set("project_id", *projectID) + } + if placementID != nil && *placementID != "" { + q.Set("placement_id", *placementID) + } + + path := fmt.Sprintf("api/v1/workspaces/%s/placement_posts", workspaceID) + if len(q) > 0 { + path += "?" + q.Encode() + } + + var resp GetPlacementPostsOutput + err := c.do( + ctx, + http.MethodGet, + path, + nil, + &resp, + withBearer(jwt), + ) + + return &resp, err +} + // ============================================================================ // Workspace Members // ============================================================================ diff --git a/tg_bot/screens/placement_details.go b/tg_bot/screens/placement_details.go index c936517..7186ea6 100644 --- a/tg_bot/screens/placement_details.go +++ b/tg_bot/screens/placement_details.go @@ -44,6 +44,28 @@ func (s *PlacementDetails) renderDetails(b *bot.Bot, mode bot.RenderMode, jwt st return } + placementPosts, err := b.Backend.GetPlacementPosts( + context.Background(), + jwt, + s.WorkspaceID, + &s.ProjectID, + &s.PlacementID, + ) + if err != nil { + log.Error().Err(err).Msg("Failed to get placement posts") + } + + var placementPost *backend.PlacementPostOutput + if placementPosts != nil { + for i := range placementPosts.PlacementPosts { + post := &placementPosts.PlacementPosts[i] + if post.PlacementID == s.PlacementID { + placementPost = post + break + } + } + } + text := "Детали размещения\n\n" channelName := formatChannelName(placement.Channel) @@ -78,6 +100,16 @@ func (s *PlacementDetails) renderDetails(b *bot.Bot, mode bot.RenderMode, jwt st } } + if placementPost != nil { + text += "\nПост\n" + if placementPost.AdPostURL != nil && *placementPost.AdPostURL != "" { + text += fmt.Sprintf("• Ссылка на пост: %s\n", *placementPost.AdPostURL) + } else { + text += "• Ссылка на пост: не найдена\n" + } + text += fmt.Sprintf("• Статус поста: %s\n", formatPlacementPostStatus(placementPost.Status)) + } + keyboard := Keyboard(Row(Button("← Назад", "back"))) b.Render(text, keyboard, mode) } @@ -129,6 +161,18 @@ 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":