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":