время в топе и тэги креативов

This commit is contained in:
Artem Tsyrulnikov
2026-01-28 12:34:00 +03:00
parent 0b25566ffb
commit 484e52c077
26 changed files with 326 additions and 36 deletions

View File

@@ -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="
)

View File

@@ -335,9 +335,8 @@ class Postgres(DatabaseBase):
project = await domain.Project.get_or_none(id=project_id, workspace_id=workspace_id) project = await domain.Project.get_or_none(id=project_id, workspace_id=workspace_id)
if not project: if not project:
raise domain.ProjectNotFound() raise domain.ProjectNotFound()
import datetime
project.deleted_at = datetime.datetime.now() project.deleted_at = timezone.now()
await project.save() await project.save()
@staticmethod @staticmethod
@@ -362,6 +361,7 @@ class Postgres(DatabaseBase):
include_archived: bool = False, include_archived: bool = False,
allowed_project_ids: set[uuid.UUID] | None = None, allowed_project_ids: set[uuid.UUID] | None = None,
created_by_user_id: uuid.UUID | None = None, created_by_user_id: uuid.UUID | None = None,
tag: domain.CreativeTag | None = None,
) -> list[domain.Creative]: ) -> list[domain.Creative]:
query = domain.Creative.filter(project__workspace_id=workspace_id) query = domain.Creative.filter(project__workspace_id=workspace_id)
@@ -375,6 +375,9 @@ class Postgres(DatabaseBase):
if created_by_user_id is not None: if created_by_user_id is not None:
query = query.filter(created_by_user_id=created_by_user_id) 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: if not include_archived:
query = query.filter(status=domain.CreativeStatus.ACTIVE) query = query.filter(status=domain.CreativeStatus.ACTIVE)
@@ -672,3 +675,32 @@ class Postgres(DatabaseBase):
@staticmethod @staticmethod
async def has_placement_posts_for_creative(creative_id: uuid.UUID) -> bool: async def has_placement_posts_for_creative(creative_id: uuid.UUID) -> bool:
return await domain.PlacementPost.filter(placement__creative_id=creative_id).exists() 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

View File

@@ -6,7 +6,7 @@ from fastapi import Depends
from fastapi.routing import APIRouter from fastapi.routing import APIRouter
from fastapi_pagination import Page, paginate from fastapi_pagination import Page, paginate
from src import deps, dto from src import deps, domain, dto
from src.adapter.jwt import JWTPayload from src.adapter.jwt import JWTPayload
analytics_router = APIRouter(prefix='/workspaces/{workspace_id}/analytics', tags=['analytics']) analytics_router = APIRouter(prefix='/workspaces/{workspace_id}/analytics', tags=['analytics'])
@@ -40,11 +40,13 @@ async def get_creatives_analytics(
workspace_id: uuid.UUID, workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
project_id: uuid.UUID | None = None, project_id: uuid.UUID | None = None,
tag: domain.CreativeTag | None = None,
) -> Page[dto.CreativeAnalyticsOutput]: ) -> Page[dto.CreativeAnalyticsOutput]:
input = dto.GetCreativesAnalyticsInput( input = dto.GetCreativesAnalyticsInput(
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_id, workspace_id=workspace_id,
project_id=project_id, project_id=project_id,
tag=tag,
) )
result = await deps.get_usecase().get_creatives_analytics(input) result = await deps.get_usecase().get_creatives_analytics(input)
return paginate(result) # type: ignore[no-any-return] return paginate(result) # type: ignore[no-any-return]

View File

@@ -35,6 +35,7 @@ __all__ = (
'ChannelNotFound', 'ChannelNotFound',
'ProjectNotFound', 'ProjectNotFound',
'CreativeStatus', 'CreativeStatus',
'CreativeTag',
'SubscriptionStatus', 'SubscriptionStatus',
'InviteLinkType', 'InviteLinkType',
'CostType', 'CostType',
@@ -76,6 +77,7 @@ from .creative import (
Creative, Creative,
CreativeMedia, CreativeMedia,
CreativeStatus, CreativeStatus,
CreativeTag,
replace_invite_link_with_tag, replace_invite_link_with_tag,
validate_media_items, validate_media_items,
validate_media_size, validate_media_size,

View File

@@ -24,11 +24,17 @@ class CreativeStatus(str, enum.Enum):
ARCHIVED = 'archived' ARCHIVED = 'archived'
class CreativeTag(str, enum.Enum):
TESTING = 'testing' # Тестовый
PRODUCTION = 'production' # Рабочий
class Creative(TimestampedModel): class Creative(TimestampedModel):
name = fields.CharField(max_length=255) name = fields.CharField(max_length=255)
text = fields.TextField() text = fields.TextField()
buttons = fields.JSONField(default=list) buttons = fields.JSONField(default=list)
status = fields.CharEnumField(CreativeStatus, default=CreativeStatus.ACTIVE) status = fields.CharEnumField(CreativeStatus, default=CreativeStatus.ACTIVE)
tag = fields.CharEnumField(CreativeTag, default=CreativeTag.TESTING)
project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField( project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
'models.Project', related_name='creatives', on_delete=fields.CASCADE, index=True 'models.Project', related_name='creatives', on_delete=fields.CASCADE, index=True

View File

@@ -13,6 +13,7 @@ class Post(TimestampedModel):
message_id = fields.IntField() message_id = fields.IntField()
text = fields.TextField() text = fields.TextField()
deleted_from_channel_at = fields.DatetimeField(null=True) deleted_from_channel_at = fields.DatetimeField(null=True)
published_at = fields.DatetimeField(null=True)
channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField( channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField(
'models.Channel', related_name='posts', on_delete=fields.CASCADE, index=True 'models.Channel', related_name='posts', on_delete=fields.CASCADE, index=True

View File

@@ -4,6 +4,8 @@ from enum import StrEnum
import pydantic import pydantic
from src import domain
class DateGrouping(StrEnum): class DateGrouping(StrEnum):
DAY = 'day' DAY = 'day'
@@ -54,6 +56,7 @@ class PlacementAnalyticsOutput(pydantic.BaseModel):
class CreativeAnalyticsOutput(pydantic.BaseModel): class CreativeAnalyticsOutput(pydantic.BaseModel):
id: uuid.UUID id: uuid.UUID
name: str name: str
tag: domain.CreativeTag
placements_count: int placements_count: int
total_cost: float total_cost: float
total_subscriptions: int total_subscriptions: int
@@ -92,6 +95,7 @@ class GetCreativesAnalyticsInput(pydantic.BaseModel):
user_id: uuid.UUID user_id: uuid.UUID
workspace_id: uuid.UUID workspace_id: uuid.UUID
project_id: uuid.UUID | None = None project_id: uuid.UUID | None = None
tag: domain.CreativeTag | None = None
class GetCreativesAnalyticsOutput(pydantic.BaseModel): class GetCreativesAnalyticsOutput(pydantic.BaseModel):

View File

@@ -33,6 +33,7 @@ class CreativeOutput(pydantic.BaseModel):
project_channel_title: str project_channel_title: str
created_at: datetime.datetime created_at: datetime.datetime
status: domain.CreativeStatus status: domain.CreativeStatus
tag: domain.CreativeTag
placements_count: int placements_count: int
@@ -66,6 +67,7 @@ class CreateCreativeInput(pydantic.BaseModel):
text: str text: str
media_items: list[CreativeMediaInput] = pydantic.Field(default_factory=list) media_items: list[CreativeMediaInput] = pydantic.Field(default_factory=list)
buttons: list[CreativeButton] = pydantic.Field(default_factory=list) buttons: list[CreativeButton] = pydantic.Field(default_factory=list)
tag: domain.CreativeTag | None = None
class UpdateCreativeInput(pydantic.BaseModel): class UpdateCreativeInput(pydantic.BaseModel):
@@ -74,6 +76,7 @@ class UpdateCreativeInput(pydantic.BaseModel):
media_items: list[CreativeMediaInput] | None = None media_items: list[CreativeMediaInput] | None = None
buttons: list[CreativeButton] | None = None buttons: list[CreativeButton] | None = None
status: domain.CreativeStatus | None = None status: domain.CreativeStatus | None = None
tag: domain.CreativeTag | None = None
class DeleteCreativeInput(pydantic.BaseModel): class DeleteCreativeInput(pydantic.BaseModel):

View File

@@ -49,6 +49,7 @@ class PlacementPostOutput(pydantic.BaseModel):
subscriptions_count: int subscriptions_count: int
views_count: int | None views_count: int | None
created_at: datetime.datetime created_at: datetime.datetime
time_on_top: int | None = None
post: PostOutput post: PostOutput

View File

@@ -42,6 +42,7 @@ async def get_creatives_analytics(
include_archived=False, include_archived=False,
allowed_project_ids=allowed_project_ids, allowed_project_ids=allowed_project_ids,
created_by_user_id=created_by_filter, created_by_user_id=created_by_filter,
tag=input.tag,
) )
placements = await self.database.get_workspace_placement_posts( placements = await self.database.get_workspace_placement_posts(
input.workspace_id, input.workspace_id,
@@ -101,6 +102,7 @@ async def get_creatives_analytics(
dto.CreativeAnalyticsOutput( dto.CreativeAnalyticsOutput(
id=cr.id, id=cr.id,
name=cr.name, name=cr.name,
tag=cr.tag,
placements_count=stats.placements_count, placements_count=stats.placements_count,
total_cost=stats.total_cost, total_cost=stats.total_cost,
total_subscriptions=total_subscriptions, total_subscriptions=total_subscriptions,

View File

@@ -4,6 +4,7 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from fastapi import HTTPException, status from fastapi import HTTPException, status
from tortoise import timezone
from src import domain, dto from src import domain, dto
@@ -278,25 +279,25 @@ async def get_projects_analytics(
# Определяем дату для period_label (берем первую дату периода) # Определяем дату для period_label (берем первую дату периода)
if input.grouping == dto.DateGrouping.DAY: 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: elif input.grouping == dto.DateGrouping.WEEK:
# ISO week format: YYYY-Www # ISO week format: YYYY-Www
year, week_str = period_key.split('-W') year, week_str = period_key.split('-W')
week = int(week_str) week = int(week_str)
# Находим первый день недели (понедельник) для данной ISO недели # Находим первый день недели (понедельник) для данной 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 jan4_weekday = jan4.weekday() # 0=Monday, 6=Sunday
days_since_monday = (jan4_weekday + 1) % 7 days_since_monday = (jan4_weekday + 1) % 7
jan4_monday = jan4 - datetime.timedelta(days=days_since_monday) jan4_monday = jan4 - datetime.timedelta(days=days_since_monday)
period_dt = jan4_monday + datetime.timedelta(weeks=week - 1) period_dt = jan4_monday + datetime.timedelta(weeks=week - 1)
elif input.grouping == dto.DateGrouping.MONTH: 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: elif input.grouping == dto.DateGrouping.QUARTER:
year, quarter = period_key.split('-Q') year, quarter = period_key.split('-Q')
month = (int(quarter) - 1) * 3 + 1 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: else:
period_dt = datetime.datetime.now() period_dt = timezone.now()
period_label = _format_period_label(period_dt, input.grouping) period_label = _format_period_label(period_dt, input.grouping)
metrics = _calculate_metrics(pd, input.metrics, hide_subscriptions) metrics = _calculate_metrics(pd, input.metrics, hide_subscriptions)

View File

@@ -33,6 +33,7 @@ async def create_creative(
text=creative_text, text=creative_text,
buttons=[button.model_dump() for button in input.buttons], buttons=[button.model_dump() for button in input.buttons],
status=domain.CreativeStatus.ACTIVE, status=domain.CreativeStatus.ACTIVE,
tag=input.tag or domain.CreativeTag.TESTING,
project_id=project.id, project_id=project.id,
created_by_user_id=user_id, created_by_user_id=user_id,
) )
@@ -50,6 +51,7 @@ async def create_creative(
project_channel_title=project.channel.title, project_channel_title=project.channel.title,
created_at=creative.created_at, created_at=creative.created_at,
status=creative.status, status=creative.status,
tag=creative.tag,
placements_count=0, placements_count=0,
) )

View File

@@ -44,5 +44,6 @@ async def get_creative(self: 'Usecase', input: dto.GetCreativeInput) -> dto.Crea
project_channel_title=creative.project.channel.title, project_channel_title=creative.project.channel.title,
created_at=creative.created_at, created_at=creative.created_at,
status=creative.status, status=creative.status,
tag=creative.tag,
placements_count=placements_count, placements_count=placements_count,
) )

View File

@@ -52,6 +52,7 @@ async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> list[d
project_channel_title=creative.project.channel.title, project_channel_title=creative.project.channel.title,
created_at=creative.created_at, created_at=creative.created_at,
status=creative.status, status=creative.status,
tag=creative.tag,
placements_count=placements_counts.get(creative.id, 0), placements_count=placements_counts.get(creative.id, 0),
) )
) )

View File

@@ -41,6 +41,8 @@ async def update_creative(
creative.buttons = [button.model_dump() for button in input.buttons] creative.buttons = [button.model_dump() for button in input.buttons]
if input.status: if input.status:
creative.status = input.status creative.status = input.status
if input.tag:
creative.tag = input.tag
await self.database.update_creative(creative) await self.database.update_creative(creative)
@@ -60,6 +62,7 @@ async def update_creative(
project_channel_title=creative.project.channel.title, project_channel_title=creative.project.channel.title,
created_at=creative.created_at, created_at=creative.created_at,
status=creative.status, status=creative.status,
tag=creative.tag,
placements_count=placements_count, placements_count=placements_count,
) )

View File

@@ -1,6 +1,8 @@
import logging import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from tortoise import timezone
from src import domain, dto from src import domain, dto
from src.usecase.purchase.create_placements import _build_placement_output 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( 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: ) -> dto.PlacementPostOutput | None:
if not placement_post.post: if not placement_post.post:
log.warning('PlacementPost %s missing post', placement_post.id) log.warning('PlacementPost %s missing post', placement_post.id)
@@ -40,6 +45,7 @@ def _build_placement_post_output(
subscriptions_count=subscriptions_count, subscriptions_count=subscriptions_count,
views_count=views_count, views_count=views_count,
created_at=placement_post.created_at, created_at=placement_post.created_at,
time_on_top=time_on_top,
post=post_output, 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)) log.warning('Placement %s has %s placement_posts, returning latest', placement.id, len(placement_posts))
for placement_post in placement_posts: for placement_post in placement_posts:
views_count = None views_count = None
time_on_top = None
if placement_post.post: if placement_post.post:
views_count = views_map.get(placement_post.post.id, (None,))[0] 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_output = _build_placement_post_output(
placement_post, placement_post,
subscriptions_count, subscriptions_count,
views_count, views_count,
time_on_top,
) )
if placement_post_output is not None: if placement_post_output is not None:
break break

View File

@@ -2,6 +2,8 @@ import logging
import uuid import uuid
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from tortoise import timezone
from src import domain, dto from src import domain, dto
from .create_placements import _build_placement_output 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] 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 {} 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]] = {} placement_posts_by_placement_id: dict[uuid.UUID, list[dto.PlacementPostOutput]] = {}
for placement_post in placement_posts: for placement_post in placement_posts:
views_count = None views_count = None
time_on_top = time_on_top_map.get(placement_post.id)
if placement_post.post: if placement_post.post:
views_count = views_map.get(placement_post.post.id, (None,))[0] views_count = views_map.get(placement_post.post.id, (None,))[0]
placement_post_output = _build_placement_post_output( placement_post_output = _build_placement_post_output(
placement_post, placement_post,
subscriptions_counts.get(placement_post.placement_id, 0), subscriptions_counts.get(placement_post.placement_id, 0),
views_count, views_count,
time_on_top,
) )
if placement_post_output is None: if placement_post_output is None:
continue continue

View File

@@ -378,6 +378,7 @@ type Creative struct {
ProjectChannelTitle string `json:"project_channel_title"` ProjectChannelTitle string `json:"project_channel_title"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
Status string `json:"status"` Status string `json:"status"`
Tag string `json:"tag"`
PlacementsCount int `json:"placements_count"` PlacementsCount int `json:"placements_count"`
} }
@@ -463,6 +464,7 @@ type CreateCreativeInput struct {
Text string `json:"text"` Text string `json:"text"`
MediaItems []CreativeMediaInput `json:"media_items,omitempty"` MediaItems []CreativeMediaInput `json:"media_items,omitempty"`
Buttons []CreativeButton `json:"buttons,omitempty"` Buttons []CreativeButton `json:"buttons,omitempty"`
Tag *string `json:"tag,omitempty"`
} }
func (c *Client) CreateCreative( func (c *Client) CreateCreative(
@@ -496,6 +498,7 @@ type UpdateCreativeInput struct {
MediaItems *[]CreativeMediaInput `json:"media_items,omitempty"` MediaItems *[]CreativeMediaInput `json:"media_items,omitempty"`
Buttons *[]CreativeButton `json:"buttons,omitempty"` Buttons *[]CreativeButton `json:"buttons,omitempty"`
Status *string `json:"status,omitempty"` Status *string `json:"status,omitempty"`
Tag *string `json:"tag,omitempty"`
} }
func (c *Client) UpdateCreative( func (c *Client) UpdateCreative(

View File

@@ -188,6 +188,18 @@ func (s *AddCreativeEdit) HandleCallback(b *bot.Bot, u *echotron.Update) {
s.ctx.MediaChanged = true s.ctx.MediaChanged = true
s.ctx.UpdateCreativePreview(b) s.ctx.UpdateCreativePreview(b)
s.Enter(b, bot.EditMessage) 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": case data == "confirm_create":
s.ctx.createCreative(b) s.ctx.createCreative(b)
case strings.HasPrefix(data, "delete_button:"): 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") log.Info().Str("text", *s.Text).Msg("Creative text")
tag := "testing"
if s.Tag != nil {
tag = *s.Tag
}
input := backend.CreateCreativeInput{ input := backend.CreateCreativeInput{
Name: *s.Name, Name: *s.Name,
Text: *s.Text, Text: *s.Text,
Buttons: buttons, Buttons: buttons,
Tag: &tag,
} }
if len(mediaItems) > 0 { if len(mediaItems) > 0 {
input.MediaItems = mediaItems input.MediaItems = mediaItems

View File

@@ -60,6 +60,9 @@ func (s *CreativeDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
for _, btn := range creative.Buttons { for _, btn := range creative.Buttons {
s.Buttons = append(s.Buttons, InlineButton{Text: btn.Text, URL: btn.URL}) s.Buttons = append(s.Buttons, InlineButton{Text: btn.Text, URL: btn.URL})
} }
if creative.Tag != "" {
s.Tag = &creative.Tag
}
s.MediaChanged = false s.MediaChanged = false
s.ClearInputMode() s.ClearInputMode()
@@ -109,6 +112,19 @@ func (s *CreativeDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
s.UpdateCreativePreview(b) s.UpdateCreativePreview(b)
s.showManagementPanelWithDelete(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": case data == "cancel_add_button":
// Отменяем добавление кнопки // Отменяем добавление кнопки
s.CancelPendingButton(b) s.CancelPendingButton(b)
@@ -293,6 +309,7 @@ func (s *CreativeDetails) updateCreative(b *bot.Bot) {
input := backend.UpdateCreativeInput{ input := backend.UpdateCreativeInput{
Name: s.Name, Name: s.Name,
Text: s.Text, Text: s.Text,
Tag: s.Tag,
} }
if s.MediaChanged { if s.MediaChanged {
if len(s.MediaItems) > 0 { if len(s.MediaItems) > 0 {

View File

@@ -31,6 +31,7 @@ type CreativeEditorFields struct {
Name *string Name *string
Text *string Text *string
Buttons []InlineButton Buttons []InlineButton
Tag *string // "testing" or "production"
// Медиа // Медиа
MediaItems []MediaItem MediaItems []MediaItem
@@ -663,6 +664,9 @@ func (e *CreativeEditorFields) BuildEditorButtons(confirmText, confirmCallback,
} }
buttons = append(buttons, Row(Button(addButtonLabel, addButtonCallback))) buttons = append(buttons, Row(Button(addButtonLabel, addButtonCallback)))
// Кнопка выбора тега
buttons = append(buttons, Row(Button(e.GetTagLabel(), "edit_tag")))
// Кнопка удаления медиа (если медиа добавлено) // Кнопка удаления медиа (если медиа добавлено)
if len(e.MediaItems) > 0 { if len(e.MediaItems) > 0 {
label := "⌫ Убрать медиа" label := "⌫ Убрать медиа"

View File

@@ -58,6 +58,42 @@ URL должен начинаться с <code>http://</code> или <code>https
` `
const msgEditTag = `
<b>🏷 Выберите тег креатива:</b>
🧪 <b>Тестовый</b> — для A/B тестов и экспериментов
🚀 <b>Рабочий</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) { func (e *CreativeEditorFields) ShowAddButtonPanel(b *bot.Bot) {
inviteLabel := "Ссылка на канал" inviteLabel := "Ссылка на канал"
customLabel := "Своя ссылка" customLabel := "Своя ссылка"

View File

@@ -11,10 +11,11 @@ import (
) )
func (d *Database) CreatePost(ctx context.Context, post domain.Post) error { func (d *Database) CreatePost(ctx context.Context, post domain.Post) error {
query := `INSERT INTO post (id, channel_id, message_id, text) query := `INSERT INTO post (id, channel_id, message_id, text, published_at)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (channel_id, message_id) DO UPDATE ON CONFLICT (channel_id, message_id) DO UPDATE
SET text = EXCLUDED.text, SET text = EXCLUDED.text,
published_at = COALESCE(post.published_at, EXCLUDED.published_at),
updated_at = CURRENT_TIMESTAMP;` updated_at = CURRENT_TIMESTAMP;`
txOrPool := transaction.TryExtractTX(ctx) txOrPool := transaction.TryExtractTX(ctx)
@@ -24,9 +25,10 @@ SET text = EXCLUDED.text,
ChannelID: pgtype.UUID{Bytes: post.ChannelID, Valid: true}, ChannelID: pgtype.UUID{Bytes: post.ChannelID, Valid: true},
MessageID: pgtype.Int4{Int32: int32(post.MessageID), Valid: true}, MessageID: pgtype.Int4{Int32: int32(post.MessageID), Valid: true},
Text: pgtype.Text{String: post.Text, 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 { if err != nil {
return fmt.Errorf("txOrPool.Exec: %w", err) return fmt.Errorf("txOrPool.Exec: %w", err)
} }
@@ -39,4 +41,5 @@ type createPostDTO struct {
ChannelID pgtype.UUID ChannelID pgtype.UUID
MessageID pgtype.Int4 MessageID pgtype.Int4
Text pgtype.Text Text pgtype.Text
PublishedAt pgtype.Timestamptz
} }

View File

@@ -3,6 +3,7 @@ package telegram
import ( import (
"context" "context"
"fmt" "fmt"
"time"
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain" "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
"github.com/gotd/td/tg" "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) 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) posts = append(posts, p)
} }
@@ -73,7 +75,7 @@ func extractDeletedPosts(channel domain.Channel, updates []tg.UpdateClass) []dom
for _, upd := range updates { for _, upd := range updates {
if u, ok := upd.(*tg.UpdateDeleteChannelMessages); ok { if u, ok := upd.(*tg.UpdateDeleteChannelMessages); ok {
for _, msgID := range u.Messages { for _, msgID := range u.Messages {
p := domain.NewPost(channel, msgID, "", 0) p := domain.NewPost(channel, msgID, "", 0, time.Time{})
deleted = append(deleted, p) deleted = append(deleted, p)
} }
} }

View File

@@ -3,6 +3,7 @@ package telegram
import ( import (
"context" "context"
"fmt" "fmt"
"time"
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain" "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
"github.com/gotd/td/tg" "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) 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) placements = append(placements, p)
} }

View File

@@ -2,6 +2,7 @@ package domain
import ( import (
"fmt" "fmt"
"time"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -13,9 +14,10 @@ type Post struct {
Text string Text string
Link string Link string
Views int 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 := "" link := ""
if channel.Username != "" { if channel.Username != "" {
link = fmt.Sprintf("https://t.me/%s/%d", channel.Username, messageID) link = fmt.Sprintf("https://t.me/%s/%d", channel.Username, messageID)
@@ -29,5 +31,6 @@ func NewPost(channel Channel, messageID int, text string, views int) Post {
Text: text, Text: text,
Link: link, Link: link,
Views: views, Views: views,
PublishedAt: publishedAt,
} }
} }