время в топе и тэги креативов
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user