diff --git a/migrations/models/12_20260128120011_add_published_at_to_post.py b/migrations/models/12_20260128120011_add_published_at_to_post.py
new file mode 100644
index 0000000..695bb48
--- /dev/null
+++ b/migrations/models/12_20260128120011_add_published_at_to_post.py
@@ -0,0 +1,99 @@
+from tortoise import BaseDBAsyncClient
+
+RUN_IN_TRANSACTION = True
+
+
+async def upgrade(db: BaseDBAsyncClient) -> str:
+ return """
+ ALTER TABLE "creative" ADD "tag" VARCHAR(10) NOT NULL DEFAULT 'testing';
+ ALTER TABLE "post" ADD "published_at" TIMESTAMPTZ;
+ COMMENT ON COLUMN "creative"."tag" IS 'TESTING: testing\nPRODUCTION: production';"""
+
+
+async def downgrade(db: BaseDBAsyncClient) -> str:
+ return """
+ ALTER TABLE "post" DROP COLUMN "published_at";
+ ALTER TABLE "creative" DROP COLUMN "tag";"""
+
+
+MODELS_STATE = (
+ "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"
+ "3tCrbZLBTbKb4PKfXt7/BaQvMYA="
+)
diff --git a/src/adapter/postgres.py b/src/adapter/postgres.py
index 9e35202..005405e 100644
--- a/src/adapter/postgres.py
+++ b/src/adapter/postgres.py
@@ -335,9 +335,8 @@ class Postgres(DatabaseBase):
project = await domain.Project.get_or_none(id=project_id, workspace_id=workspace_id)
if not project:
raise domain.ProjectNotFound()
- import datetime
- project.deleted_at = datetime.datetime.now()
+ project.deleted_at = timezone.now()
await project.save()
@staticmethod
@@ -362,6 +361,7 @@ class Postgres(DatabaseBase):
include_archived: bool = False,
allowed_project_ids: set[uuid.UUID] | None = None,
created_by_user_id: uuid.UUID | None = None,
+ tag: domain.CreativeTag | None = None,
) -> list[domain.Creative]:
query = domain.Creative.filter(project__workspace_id=workspace_id)
@@ -375,6 +375,9 @@ class Postgres(DatabaseBase):
if created_by_user_id is not None:
query = query.filter(created_by_user_id=created_by_user_id)
+ if tag is not None:
+ query = query.filter(tag=tag)
+
if not include_archived:
query = query.filter(status=domain.CreativeStatus.ACTIVE)
@@ -672,3 +675,32 @@ class Postgres(DatabaseBase):
@staticmethod
async def has_placement_posts_for_creative(creative_id: uuid.UUID) -> bool:
return await domain.PlacementPost.filter(placement__creative_id=creative_id).exists()
+
+ @staticmethod
+ async def get_next_post_after(channel_id: uuid.UUID, message_id: int) -> domain.Post | None:
+ """Get first post in channel after the given message_id."""
+ return await domain.Post.filter(
+ channel_id=channel_id,
+ message_id__gt=message_id,
+ deleted_from_channel_at__isnull=True,
+ ).order_by('message_id').first()
+
+ @staticmethod
+ async def get_next_posts_after_batch(
+ channel_message_pairs: list[tuple[uuid.UUID, int]]
+ ) -> dict[tuple[uuid.UUID, int], domain.Post]:
+ """Batch version: get next post for each (channel_id, message_id) pair."""
+ if not channel_message_pairs:
+ return {}
+
+ results: dict[tuple[uuid.UUID, int], domain.Post] = {}
+ for channel_id, message_id in channel_message_pairs:
+ next_post = await domain.Post.filter(
+ channel_id=channel_id,
+ message_id__gt=message_id,
+ deleted_from_channel_at__isnull=True,
+ ).order_by('message_id').first()
+ if next_post:
+ results[(channel_id, message_id)] = next_post
+
+ return results
diff --git a/src/controller/http_v1/analytics.py b/src/controller/http_v1/analytics.py
index 99b7c02..f4be026 100644
--- a/src/controller/http_v1/analytics.py
+++ b/src/controller/http_v1/analytics.py
@@ -6,7 +6,7 @@ from fastapi import Depends
from fastapi.routing import APIRouter
from fastapi_pagination import Page, paginate
-from src import deps, dto
+from src import deps, domain, dto
from src.adapter.jwt import JWTPayload
analytics_router = APIRouter(prefix='/workspaces/{workspace_id}/analytics', tags=['analytics'])
@@ -40,11 +40,13 @@ async def get_creatives_analytics(
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
project_id: uuid.UUID | None = None,
+ tag: domain.CreativeTag | None = None,
) -> Page[dto.CreativeAnalyticsOutput]:
input = dto.GetCreativesAnalyticsInput(
user_id=current_user.user_id,
workspace_id=workspace_id,
project_id=project_id,
+ tag=tag,
)
result = await deps.get_usecase().get_creatives_analytics(input)
return paginate(result) # type: ignore[no-any-return]
diff --git a/src/domain/__init__.py b/src/domain/__init__.py
index 305bdc3..56f6d7b 100644
--- a/src/domain/__init__.py
+++ b/src/domain/__init__.py
@@ -35,6 +35,7 @@ __all__ = (
'ChannelNotFound',
'ProjectNotFound',
'CreativeStatus',
+ 'CreativeTag',
'SubscriptionStatus',
'InviteLinkType',
'CostType',
@@ -76,6 +77,7 @@ from .creative import (
Creative,
CreativeMedia,
CreativeStatus,
+ CreativeTag,
replace_invite_link_with_tag,
validate_media_items,
validate_media_size,
diff --git a/src/domain/creative.py b/src/domain/creative.py
index 911ac83..fd01885 100644
--- a/src/domain/creative.py
+++ b/src/domain/creative.py
@@ -24,11 +24,17 @@ class CreativeStatus(str, enum.Enum):
ARCHIVED = 'archived'
+class CreativeTag(str, enum.Enum):
+ TESTING = 'testing' # Тестовый
+ PRODUCTION = 'production' # Рабочий
+
+
class Creative(TimestampedModel):
name = fields.CharField(max_length=255)
text = fields.TextField()
buttons = fields.JSONField(default=list)
status = fields.CharEnumField(CreativeStatus, default=CreativeStatus.ACTIVE)
+ tag = fields.CharEnumField(CreativeTag, default=CreativeTag.TESTING)
project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
'models.Project', related_name='creatives', on_delete=fields.CASCADE, index=True
diff --git a/src/domain/post.py b/src/domain/post.py
index c867d0c..54b2a73 100644
--- a/src/domain/post.py
+++ b/src/domain/post.py
@@ -13,6 +13,7 @@ class Post(TimestampedModel):
message_id = fields.IntField()
text = fields.TextField()
deleted_from_channel_at = fields.DatetimeField(null=True)
+ published_at = fields.DatetimeField(null=True)
channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField(
'models.Channel', related_name='posts', on_delete=fields.CASCADE, index=True
diff --git a/src/dto/analytics.py b/src/dto/analytics.py
index 8ab558f..b795089 100644
--- a/src/dto/analytics.py
+++ b/src/dto/analytics.py
@@ -4,6 +4,8 @@ from enum import StrEnum
import pydantic
+from src import domain
+
class DateGrouping(StrEnum):
DAY = 'day'
@@ -54,6 +56,7 @@ class PlacementAnalyticsOutput(pydantic.BaseModel):
class CreativeAnalyticsOutput(pydantic.BaseModel):
id: uuid.UUID
name: str
+ tag: domain.CreativeTag
placements_count: int
total_cost: float
total_subscriptions: int
@@ -92,6 +95,7 @@ class GetCreativesAnalyticsInput(pydantic.BaseModel):
user_id: uuid.UUID
workspace_id: uuid.UUID
project_id: uuid.UUID | None = None
+ tag: domain.CreativeTag | None = None
class GetCreativesAnalyticsOutput(pydantic.BaseModel):
diff --git a/src/dto/creative.py b/src/dto/creative.py
index 66d7bb3..697106c 100644
--- a/src/dto/creative.py
+++ b/src/dto/creative.py
@@ -33,6 +33,7 @@ class CreativeOutput(pydantic.BaseModel):
project_channel_title: str
created_at: datetime.datetime
status: domain.CreativeStatus
+ tag: domain.CreativeTag
placements_count: int
@@ -66,6 +67,7 @@ class CreateCreativeInput(pydantic.BaseModel):
text: str
media_items: list[CreativeMediaInput] = pydantic.Field(default_factory=list)
buttons: list[CreativeButton] = pydantic.Field(default_factory=list)
+ tag: domain.CreativeTag | None = None
class UpdateCreativeInput(pydantic.BaseModel):
@@ -74,6 +76,7 @@ class UpdateCreativeInput(pydantic.BaseModel):
media_items: list[CreativeMediaInput] | None = None
buttons: list[CreativeButton] | None = None
status: domain.CreativeStatus | None = None
+ tag: domain.CreativeTag | None = None
class DeleteCreativeInput(pydantic.BaseModel):
diff --git a/src/dto/purchase.py b/src/dto/purchase.py
index 466340c..28de07b 100644
--- a/src/dto/purchase.py
+++ b/src/dto/purchase.py
@@ -49,6 +49,7 @@ class PlacementPostOutput(pydantic.BaseModel):
subscriptions_count: int
views_count: int | None
created_at: datetime.datetime
+ time_on_top: int | None = None
post: PostOutput
diff --git a/src/usecase/analytics/get_creatives_analytics.py b/src/usecase/analytics/get_creatives_analytics.py
index b57ae44..d28e0f8 100644
--- a/src/usecase/analytics/get_creatives_analytics.py
+++ b/src/usecase/analytics/get_creatives_analytics.py
@@ -42,6 +42,7 @@ async def get_creatives_analytics(
include_archived=False,
allowed_project_ids=allowed_project_ids,
created_by_user_id=created_by_filter,
+ tag=input.tag,
)
placements = await self.database.get_workspace_placement_posts(
input.workspace_id,
@@ -101,6 +102,7 @@ async def get_creatives_analytics(
dto.CreativeAnalyticsOutput(
id=cr.id,
name=cr.name,
+ tag=cr.tag,
placements_count=stats.placements_count,
total_cost=stats.total_cost,
total_subscriptions=total_subscriptions,
diff --git a/src/usecase/analytics/get_projects_analytics.py b/src/usecase/analytics/get_projects_analytics.py
index a8748d5..24b359e 100644
--- a/src/usecase/analytics/get_projects_analytics.py
+++ b/src/usecase/analytics/get_projects_analytics.py
@@ -4,6 +4,7 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING
from fastapi import HTTPException, status
+from tortoise import timezone
from src import domain, dto
@@ -278,25 +279,25 @@ async def get_projects_analytics(
# Определяем дату для period_label (берем первую дату периода)
if input.grouping == dto.DateGrouping.DAY:
- period_dt = datetime.datetime.strptime(period_key, '%Y-%m-%d')
+ period_dt = datetime.datetime.strptime(period_key, '%Y-%m-%d').replace(tzinfo=datetime.UTC)
elif input.grouping == dto.DateGrouping.WEEK:
# ISO week format: YYYY-Www
year, week_str = period_key.split('-W')
week = int(week_str)
# Находим первый день недели (понедельник) для данной ISO недели
- jan4 = datetime.datetime(int(year), 1, 4)
+ jan4 = datetime.datetime(int(year), 1, 4, tzinfo=datetime.UTC)
jan4_weekday = jan4.weekday() # 0=Monday, 6=Sunday
days_since_monday = (jan4_weekday + 1) % 7
jan4_monday = jan4 - datetime.timedelta(days=days_since_monday)
period_dt = jan4_monday + datetime.timedelta(weeks=week - 1)
elif input.grouping == dto.DateGrouping.MONTH:
- period_dt = datetime.datetime.strptime(period_key, '%Y-%m')
+ period_dt = datetime.datetime.strptime(period_key, '%Y-%m').replace(tzinfo=datetime.UTC)
elif input.grouping == dto.DateGrouping.QUARTER:
year, quarter = period_key.split('-Q')
month = (int(quarter) - 1) * 3 + 1
- period_dt = datetime.datetime(int(year), month, 1)
+ period_dt = datetime.datetime(int(year), month, 1, tzinfo=datetime.UTC)
else:
- period_dt = datetime.datetime.now()
+ period_dt = timezone.now()
period_label = _format_period_label(period_dt, input.grouping)
metrics = _calculate_metrics(pd, input.metrics, hide_subscriptions)
diff --git a/src/usecase/creative/create_creative.py b/src/usecase/creative/create_creative.py
index 83f541d..f175242 100644
--- a/src/usecase/creative/create_creative.py
+++ b/src/usecase/creative/create_creative.py
@@ -33,6 +33,7 @@ async def create_creative(
text=creative_text,
buttons=[button.model_dump() for button in input.buttons],
status=domain.CreativeStatus.ACTIVE,
+ tag=input.tag or domain.CreativeTag.TESTING,
project_id=project.id,
created_by_user_id=user_id,
)
@@ -50,6 +51,7 @@ async def create_creative(
project_channel_title=project.channel.title,
created_at=creative.created_at,
status=creative.status,
+ tag=creative.tag,
placements_count=0,
)
diff --git a/src/usecase/creative/get_creative.py b/src/usecase/creative/get_creative.py
index 13adbf6..5ad21fd 100644
--- a/src/usecase/creative/get_creative.py
+++ b/src/usecase/creative/get_creative.py
@@ -44,5 +44,6 @@ async def get_creative(self: 'Usecase', input: dto.GetCreativeInput) -> dto.Crea
project_channel_title=creative.project.channel.title,
created_at=creative.created_at,
status=creative.status,
+ tag=creative.tag,
placements_count=placements_count,
)
diff --git a/src/usecase/creative/get_creatives.py b/src/usecase/creative/get_creatives.py
index f0e7b9d..db2fd8e 100644
--- a/src/usecase/creative/get_creatives.py
+++ b/src/usecase/creative/get_creatives.py
@@ -52,6 +52,7 @@ async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> list[d
project_channel_title=creative.project.channel.title,
created_at=creative.created_at,
status=creative.status,
+ tag=creative.tag,
placements_count=placements_counts.get(creative.id, 0),
)
)
diff --git a/src/usecase/creative/update_creative.py b/src/usecase/creative/update_creative.py
index 472cb88..edd11d7 100644
--- a/src/usecase/creative/update_creative.py
+++ b/src/usecase/creative/update_creative.py
@@ -41,6 +41,8 @@ async def update_creative(
creative.buttons = [button.model_dump() for button in input.buttons]
if input.status:
creative.status = input.status
+ if input.tag:
+ creative.tag = input.tag
await self.database.update_creative(creative)
@@ -60,6 +62,7 @@ async def update_creative(
project_channel_title=creative.project.channel.title,
created_at=creative.created_at,
status=creative.status,
+ tag=creative.tag,
placements_count=placements_count,
)
diff --git a/src/usecase/purchase/get_placement.py b/src/usecase/purchase/get_placement.py
index d413ce2..f22333c 100644
--- a/src/usecase/purchase/get_placement.py
+++ b/src/usecase/purchase/get_placement.py
@@ -1,6 +1,8 @@
import logging
from typing import TYPE_CHECKING
+from tortoise import timezone
+
from src import domain, dto
from src.usecase.purchase.create_placements import _build_placement_output
@@ -24,7 +26,10 @@ def _build_post_output(post: domain.Post) -> dto.PostOutput:
def _build_placement_post_output(
- placement_post: domain.PlacementPost, subscriptions_count: int, views_count: int | None
+ placement_post: domain.PlacementPost,
+ subscriptions_count: int,
+ views_count: int | None,
+ time_on_top: int | None = None,
) -> dto.PlacementPostOutput | None:
if not placement_post.post:
log.warning('PlacementPost %s missing post', placement_post.id)
@@ -40,6 +45,7 @@ def _build_placement_post_output(
subscriptions_count=subscriptions_count,
views_count=views_count,
created_at=placement_post.created_at,
+ time_on_top=time_on_top,
post=post_output,
)
@@ -85,12 +91,21 @@ async def get_placement_user(self: 'Usecase', input: dto.GetPlacementInput) -> d
log.warning('Placement %s has %s placement_posts, returning latest', placement.id, len(placement_posts))
for placement_post in placement_posts:
views_count = None
+ time_on_top = None
if placement_post.post:
views_count = views_map.get(placement_post.post.id, (None,))[0]
+ # Calculate time_on_top
+ post = placement_post.post
+ next_post = await self.database.get_next_post_after(post.channel_id, post.message_id)
+ if next_post and next_post.published_at and post.published_at:
+ time_on_top = int((next_post.published_at - post.published_at).total_seconds())
+ elif post.published_at:
+ time_on_top = int((timezone.now() - post.published_at).total_seconds())
placement_post_output = _build_placement_post_output(
placement_post,
subscriptions_count,
views_count,
+ time_on_top,
)
if placement_post_output is not None:
break
diff --git a/src/usecase/purchase/get_placements.py b/src/usecase/purchase/get_placements.py
index 3f54568..d83a98d 100644
--- a/src/usecase/purchase/get_placements.py
+++ b/src/usecase/purchase/get_placements.py
@@ -2,6 +2,8 @@ import logging
import uuid
from typing import TYPE_CHECKING
+from tortoise import timezone
+
from src import domain, dto
from .create_placements import _build_placement_output
@@ -39,15 +41,40 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.
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 {}
+ # Collect (channel_id, message_id) pairs for batch next post lookup
+ channel_message_pairs = [
+ (placement_post.post.channel_id, placement_post.post.message_id)
+ for placement_post in placement_posts
+ if placement_post.post
+ ]
+ next_posts_map = await self.database.get_next_posts_after_batch(channel_message_pairs)
+
+ # Calculate time_on_top for each placement_post
+ time_on_top_map: dict[uuid.UUID, int] = {}
+ now = timezone.now()
+ for placement_post in placement_posts:
+ post = placement_post.post
+ if not post or not post.published_at:
+ continue
+ published_at = post.published_at
+ key = (post.channel_id, post.message_id)
+ next_post = next_posts_map.get(key)
+ if next_post and next_post.published_at:
+ time_on_top_map[placement_post.id] = int((next_post.published_at - published_at).total_seconds())
+ else:
+ time_on_top_map[placement_post.id] = int((now - published_at).total_seconds())
+
placement_posts_by_placement_id: dict[uuid.UUID, list[dto.PlacementPostOutput]] = {}
for placement_post in placement_posts:
views_count = None
+ time_on_top = time_on_top_map.get(placement_post.id)
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.placement_id, 0),
views_count,
+ time_on_top,
)
if placement_post_output is None:
continue
diff --git a/tg_bot/backend/backend_client.go b/tg_bot/backend/backend_client.go
index 37dfaa3..e03f046 100644
--- a/tg_bot/backend/backend_client.go
+++ b/tg_bot/backend/backend_client.go
@@ -378,6 +378,7 @@ type Creative struct {
ProjectChannelTitle string `json:"project_channel_title"`
CreatedAt string `json:"created_at"`
Status string `json:"status"`
+ Tag string `json:"tag"`
PlacementsCount int `json:"placements_count"`
}
@@ -463,6 +464,7 @@ type CreateCreativeInput struct {
Text string `json:"text"`
MediaItems []CreativeMediaInput `json:"media_items,omitempty"`
Buttons []CreativeButton `json:"buttons,omitempty"`
+ Tag *string `json:"tag,omitempty"`
}
func (c *Client) CreateCreative(
@@ -496,6 +498,7 @@ type UpdateCreativeInput struct {
MediaItems *[]CreativeMediaInput `json:"media_items,omitempty"`
Buttons *[]CreativeButton `json:"buttons,omitempty"`
Status *string `json:"status,omitempty"`
+ Tag *string `json:"tag,omitempty"`
}
func (c *Client) UpdateCreative(
diff --git a/tg_bot/screens/add_creative.go b/tg_bot/screens/add_creative.go
index 2ee18cf..83f8e80 100644
--- a/tg_bot/screens/add_creative.go
+++ b/tg_bot/screens/add_creative.go
@@ -188,6 +188,18 @@ func (s *AddCreativeEdit) HandleCallback(b *bot.Bot, u *echotron.Update) {
s.ctx.MediaChanged = true
s.ctx.UpdateCreativePreview(b)
s.Enter(b, bot.EditMessage)
+ case data == "edit_tag":
+ s.ctx.ShowTagPanel(b)
+ case data == "tag_testing":
+ tag := "testing"
+ s.ctx.Tag = &tag
+ s.Enter(b, bot.EditMessage)
+ case data == "tag_production":
+ tag := "production"
+ s.ctx.Tag = &tag
+ s.Enter(b, bot.EditMessage)
+ case data == "cancel_edit":
+ s.Enter(b, bot.EditMessage)
case data == "confirm_create":
s.ctx.createCreative(b)
case strings.HasPrefix(data, "delete_button:"):
@@ -428,10 +440,16 @@ func (s *AddCreativeCtx) createCreative(b *bot.Bot) {
log.Info().Str("text", *s.Text).Msg("Creative text")
+ tag := "testing"
+ if s.Tag != nil {
+ tag = *s.Tag
+ }
+
input := backend.CreateCreativeInput{
Name: *s.Name,
Text: *s.Text,
Buttons: buttons,
+ Tag: &tag,
}
if len(mediaItems) > 0 {
input.MediaItems = mediaItems
diff --git a/tg_bot/screens/creative_details.go b/tg_bot/screens/creative_details.go
index d11d2ba..b3d150a 100644
--- a/tg_bot/screens/creative_details.go
+++ b/tg_bot/screens/creative_details.go
@@ -60,6 +60,9 @@ func (s *CreativeDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
for _, btn := range creative.Buttons {
s.Buttons = append(s.Buttons, InlineButton{Text: btn.Text, URL: btn.URL})
}
+ if creative.Tag != "" {
+ s.Tag = &creative.Tag
+ }
s.MediaChanged = false
s.ClearInputMode()
@@ -109,6 +112,19 @@ func (s *CreativeDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
s.UpdateCreativePreview(b)
s.showManagementPanelWithDelete(b)
+ case data == "edit_tag":
+ s.ShowTagPanel(b)
+
+ case data == "tag_testing":
+ tag := "testing"
+ s.Tag = &tag
+ s.showManagementPanelWithDelete(b)
+
+ case data == "tag_production":
+ tag := "production"
+ s.Tag = &tag
+ s.showManagementPanelWithDelete(b)
+
case data == "cancel_add_button":
// Отменяем добавление кнопки
s.CancelPendingButton(b)
@@ -293,6 +309,7 @@ func (s *CreativeDetails) updateCreative(b *bot.Bot) {
input := backend.UpdateCreativeInput{
Name: s.Name,
Text: s.Text,
+ Tag: s.Tag,
}
if s.MediaChanged {
if len(s.MediaItems) > 0 {
diff --git a/tg_bot/screens/creative_editor.go b/tg_bot/screens/creative_editor.go
index e367dcf..a5e1065 100644
--- a/tg_bot/screens/creative_editor.go
+++ b/tg_bot/screens/creative_editor.go
@@ -31,6 +31,7 @@ type CreativeEditorFields struct {
Name *string
Text *string
Buttons []InlineButton
+ Tag *string // "testing" or "production"
// Медиа
MediaItems []MediaItem
@@ -663,6 +664,9 @@ func (e *CreativeEditorFields) BuildEditorButtons(confirmText, confirmCallback,
}
buttons = append(buttons, Row(Button(addButtonLabel, addButtonCallback)))
+ // Кнопка выбора тега
+ buttons = append(buttons, Row(Button(e.GetTagLabel(), "edit_tag")))
+
// Кнопка удаления медиа (если медиа добавлено)
if len(e.MediaItems) > 0 {
label := "⌫ Убрать медиа"
diff --git a/tg_bot/screens/creative_editor_ui.go b/tg_bot/screens/creative_editor_ui.go
index a6a0736..424275b 100644
--- a/tg_bot/screens/creative_editor_ui.go
+++ b/tg_bot/screens/creative_editor_ui.go
@@ -58,6 +58,42 @@ URL должен начинаться с http:// или https
`
+const msgEditTag = `
+🏷 Выберите тег креатива:
+
+🧪 Тестовый — для A/B тестов и экспериментов
+🚀 Рабочий — проверенный эффективный креатив
+
+`
+
+func (e *CreativeEditorFields) ShowTagPanel(b *bot.Bot) {
+ testingLabel := "🧪 Тестовый"
+ productionLabel := "🚀 Рабочий"
+
+ if e.Tag != nil {
+ if *e.Tag == "testing" {
+ testingLabel = "✓ 🧪 Тестовый"
+ } else if *e.Tag == "production" {
+ productionLabel = "✓ 🚀 Рабочий"
+ }
+ }
+
+ e.ShowControlPanel(b, msgEditTag, [][]echotron.InlineKeyboardButton{
+ Row(
+ Button(testingLabel, "tag_testing"),
+ Button(productionLabel, "tag_production"),
+ ),
+ Row(Button("« Назад", "cancel_edit")),
+ })
+}
+
+func (e *CreativeEditorFields) GetTagLabel() string {
+ if e.Tag != nil && *e.Tag == "production" {
+ return "🏷 Рабочий"
+ }
+ return "🏷 Тестовый"
+}
+
func (e *CreativeEditorFields) ShowAddButtonPanel(b *bot.Bot) {
inviteLabel := "Ссылка на канал"
customLabel := "Своя ссылка"
diff --git a/tg_parser/internal/adapter/database/create_post.go b/tg_parser/internal/adapter/database/create_post.go
index 0e1c1a2..0a2fe11 100644
--- a/tg_parser/internal/adapter/database/create_post.go
+++ b/tg_parser/internal/adapter/database/create_post.go
@@ -11,22 +11,24 @@ import (
)
func (d *Database) CreatePost(ctx context.Context, post domain.Post) error {
- query := `INSERT INTO post (id, channel_id, message_id, text)
-VALUES ($1, $2, $3, $4)
+ query := `INSERT INTO post (id, channel_id, message_id, text, published_at)
+VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (channel_id, message_id) DO UPDATE
SET text = EXCLUDED.text,
+ published_at = COALESCE(post.published_at, EXCLUDED.published_at),
updated_at = CURRENT_TIMESTAMP;`
txOrPool := transaction.TryExtractTX(ctx)
dto := createPostDTO{
- ID: pgtype.UUID{Bytes: post.ID, Valid: true},
- ChannelID: pgtype.UUID{Bytes: post.ChannelID, Valid: true},
- MessageID: pgtype.Int4{Int32: int32(post.MessageID), Valid: true},
- Text: pgtype.Text{String: post.Text, Valid: true},
+ ID: pgtype.UUID{Bytes: post.ID, Valid: true},
+ ChannelID: pgtype.UUID{Bytes: post.ChannelID, Valid: true},
+ MessageID: pgtype.Int4{Int32: int32(post.MessageID), Valid: true},
+ Text: pgtype.Text{String: post.Text, Valid: true},
+ PublishedAt: pgtype.Timestamptz{Time: post.PublishedAt, Valid: !post.PublishedAt.IsZero()},
}
- _, err := txOrPool.Exec(ctx, query, dto.ID, dto.ChannelID, dto.MessageID, dto.Text)
+ _, err := txOrPool.Exec(ctx, query, dto.ID, dto.ChannelID, dto.MessageID, dto.Text, dto.PublishedAt)
if err != nil {
return fmt.Errorf("txOrPool.Exec: %w", err)
}
@@ -35,8 +37,9 @@ SET text = EXCLUDED.text,
}
type createPostDTO struct {
- ID pgtype.UUID
- ChannelID pgtype.UUID
- MessageID pgtype.Int4
- Text pgtype.Text
+ ID pgtype.UUID
+ ChannelID pgtype.UUID
+ MessageID pgtype.Int4
+ Text pgtype.Text
+ PublishedAt pgtype.Timestamptz
}
diff --git a/tg_parser/internal/adapter/telegram/get_channel_diff.go b/tg_parser/internal/adapter/telegram/get_channel_diff.go
index f5c008b..fd62da4 100644
--- a/tg_parser/internal/adapter/telegram/get_channel_diff.go
+++ b/tg_parser/internal/adapter/telegram/get_channel_diff.go
@@ -3,6 +3,7 @@ package telegram
import (
"context"
"fmt"
+ "time"
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
"github.com/gotd/td/tg"
@@ -60,7 +61,8 @@ func extractPosts(channel domain.Channel, msgs []tg.MessageClass) []domain.Post
}
text := messageToHTML(m.Message, m.Entities)
- p := domain.NewPost(channel, m.ID, text, m.Views)
+ publishedAt := time.Unix(int64(m.Date), 0).UTC()
+ p := domain.NewPost(channel, m.ID, text, m.Views, publishedAt)
posts = append(posts, p)
}
@@ -73,7 +75,7 @@ func extractDeletedPosts(channel domain.Channel, updates []tg.UpdateClass) []dom
for _, upd := range updates {
if u, ok := upd.(*tg.UpdateDeleteChannelMessages); ok {
for _, msgID := range u.Messages {
- p := domain.NewPost(channel, msgID, "", 0)
+ p := domain.NewPost(channel, msgID, "", 0, time.Time{})
deleted = append(deleted, p)
}
}
diff --git a/tg_parser/internal/adapter/telegram/history.go b/tg_parser/internal/adapter/telegram/history.go
index 9bc2854..79a4e99 100644
--- a/tg_parser/internal/adapter/telegram/history.go
+++ b/tg_parser/internal/adapter/telegram/history.go
@@ -3,6 +3,7 @@ package telegram
import (
"context"
"fmt"
+ "time"
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
"github.com/gotd/td/tg"
@@ -39,7 +40,8 @@ func (t *Telegram) GetChannelHistory(ctx context.Context, channel domain.Channel
}
text := messageToHTML(m.Message, m.Entities)
- p := domain.NewPost(channel, m.ID, text, m.Views)
+ publishedAt := time.Unix(int64(m.Date), 0).UTC()
+ p := domain.NewPost(channel, m.ID, text, m.Views, publishedAt)
placements = append(placements, p)
}
diff --git a/tg_parser/internal/domain/post.go b/tg_parser/internal/domain/post.go
index 8e6f90d..7f5d452 100644
--- a/tg_parser/internal/domain/post.go
+++ b/tg_parser/internal/domain/post.go
@@ -2,20 +2,22 @@ package domain
import (
"fmt"
+ "time"
"github.com/google/uuid"
)
type Post struct {
- ID uuid.UUID
- ChannelID uuid.UUID
- MessageID int
- Text string
- Link string
- Views int
+ ID uuid.UUID
+ ChannelID uuid.UUID
+ MessageID int
+ Text string
+ Link string
+ Views int
+ PublishedAt time.Time
}
-func NewPost(channel Channel, messageID int, text string, views int) Post {
+func NewPost(channel Channel, messageID int, text string, views int, publishedAt time.Time) Post {
link := ""
if channel.Username != "" {
link = fmt.Sprintf("https://t.me/%s/%d", channel.Username, messageID)
@@ -23,11 +25,12 @@ func NewPost(channel Channel, messageID int, text string, views int) Post {
link = fmt.Sprintf("https://t.me/c/%d/%d", ChannelIDFromChatID(channel.TelegramID), messageID)
}
return Post{
- ID: uuid.New(),
- ChannelID: channel.ID,
- MessageID: messageID,
- Text: text,
- Link: link,
- Views: views,
+ ID: uuid.New(),
+ ChannelID: channel.ID,
+ MessageID: messageID,
+ Text: text,
+ Link: link,
+ Views: views,
+ PublishedAt: publishedAt,
}
}