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

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

@@ -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

View File

@@ -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]

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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):

View File

@@ -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):

View File

@@ -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

View File

@@ -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,

View File

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

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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),
)
)

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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