время в топе и тэги креативов
This commit is contained in:
@@ -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="
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 := "⌫ Убрать медиа"
|
||||||
|
|||||||
@@ -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 := "Своя ссылка"
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user