статусы

This commit is contained in:
Artem Tsyrulnikov
2026-01-28 17:15:28 +03:00
parent e87958fb60
commit 2627cafa91
11 changed files with 333 additions and 3 deletions

View File

@@ -95,6 +95,26 @@ async def build_placement_creative(
)
@placements_user_router.patch('/{placement_id}/posts/{placement_post_id}')
async def update_placement_post(
placement_id: uuid.UUID,
placement_post_id: uuid.UUID,
request: dto.UpdatePlacementPostInput,
workspace_id: uuid.UUID,
project_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.PlacementWithPostsOutput:
"""Update placement post status"""
return await deps.get_usecase().update_placement_post(
placement_id=placement_id,
placement_post_id=placement_post_id,
input=request,
workspace_id=workspace_id,
project_id=project_id,
user_id=current_user.user_id,
)
@placements_user_router.delete('/{placement_id}')
async def delete_placement(
placement_id: uuid.UUID,

View File

@@ -19,7 +19,9 @@ __all__ = (
'Placement',
'PlacementStatus',
'PlacementType',
'parse_format_duration',
'PlacementPost',
'PlacementPostStatus',
'Creative',
'CreativeMedia',
'replace_invite_link_with_tag',
@@ -119,8 +121,9 @@ from .placement import (
Placement,
PlacementStatus,
PlacementType,
parse_format_duration,
)
from .placement_post import PlacementPost
from .placement_post import PlacementPost, PlacementPostStatus
from .post import Post
from .post_views_history import PostViewsHistory
from .project import Project, ProjectStatus

View File

@@ -1,4 +1,5 @@
import enum
import re
import uuid
from typing import TYPE_CHECKING
@@ -11,7 +12,7 @@ if TYPE_CHECKING:
from .creative import Creative
from .project import Project
__all__ = ['Placement', 'PlacementStatus', 'PlacementType', 'InviteLinkType', 'CostType']
__all__ = ['Placement', 'PlacementStatus', 'PlacementType', 'InviteLinkType', 'CostType', 'parse_format_duration']
class CostType(enum.StrEnum):
@@ -42,6 +43,46 @@ class InviteLinkType(enum.StrEnum):
APPROVAL = 'approval' # с одобрением ботом
def parse_format_duration(format_str: str | None) -> int | None:
"""
Парсит формат размещения и возвращает длительность в секундах.
Примеры форматов:
- "1 / 24" -> 86400 сек (24 часа)
- "1/48" -> 172800 сек (48 часов)
- "1 / 72" -> 259200 сек (72 часа)
- "1 / (7 дней)" -> 604800 сек (7 дней)
- "1 / (30 дней)" -> 2592000 сек (30 дней)
- "1 / (без удаления)" -> None (не удалять)
- "2 / 24" -> 86400 сек (24 часа)
Returns:
int | None: Длительность в секундах или None для "без удаления"
"""
if not format_str:
return None
format_str = format_str.strip().lower()
# Проверяем "без удаления"
if 'без удаления' in format_str:
return None
# Ищем число часов (например "24", "48", "72")
hours_match = re.search(r'/\s*(\d+)\s*$', format_str)
if hours_match:
hours = int(hours_match.group(1))
return hours * 3600 # конвертируем в секунды
# Ищем дни (например "(7 дней)", "(30 дней)")
days_match = re.search(r'\((\d+)\s*дн', format_str)
if days_match:
days = int(days_match.group(1))
return days * 24 * 3600 # конвертируем в секунды
return None
class Placement(TimestampedModel):
status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.NO_STATUS)
placement_at = fields.DatetimeField(null=True)

View File

@@ -1,3 +1,4 @@
import enum
from typing import TYPE_CHECKING
from uuid import UUID
@@ -9,7 +10,26 @@ if TYPE_CHECKING:
from .placement import Placement
from .post import Post
__all__ = ['PlacementPost']
__all__ = ['PlacementPost', 'PlacementPostStatus']
class PlacementPostStatus(enum.StrEnum):
# Ручные статусы
NO_STATUS = 'Без статуса'
SEND_POST = 'Отправить пост'
POST_APPROVAL = 'Согласование поста'
WAITING_SCHEDULE = 'Ожидание отложки'
SCHEDULED = 'Запланирован'
# Автоматические статусы
POST_PUBLISHED = 'Пост вышел'
COMPLETED_DELETED = 'Размещение отработало - пост удалён'
COMPLETED_NOT_DELETED = 'Размещение отработало - пост не удалён'
# Проверить (требуют внимания)
CHECK_DELETED_EARLY = 'Проверить - пост удалён раньше срока'
CHECK_NOT_PUBLISHED = 'Проверить - пост не вышел'
CHECK_COMPLETED = 'Размещение отработало' # ручной после проверки
class PlacementPost(TimestampedModel):
@@ -19,6 +39,7 @@ class PlacementPost(TimestampedModel):
post: fields.ForeignKeyRelation['Post'] | None = fields.ForeignKeyField(
'models.Post', related_name='placement_posts', on_delete=fields.SET_NULL, null=True, index=True
)
status = fields.CharEnumField(PlacementPostStatus, default=PlacementPostStatus.NO_STATUS, max_length=64)
if TYPE_CHECKING:
post_id: UUID | None

View File

@@ -31,6 +31,7 @@ __all__ = (
'GetPlacementInput',
'UpdatePlacementInput',
'DeletePlacementInput',
'UpdatePlacementPostInput',
'PlacementPostOutput',
'PostOutput',
'PlacementWithPostsOutput',
@@ -173,6 +174,7 @@ from .purchase import (
PlacementWithPostsOutput,
PostOutput,
UpdatePlacementInput,
UpdatePlacementPostInput,
)
from .user import UserOutput
from .validate_login_token import ValidateLoginTokenInput, ValidateLoginTokenOutput

View File

@@ -4,6 +4,7 @@ import uuid
import pydantic
from src.domain.placement import CostType, InviteLinkType, PlacementStatus, PlacementType
from src.domain.placement_post import PlacementPostStatus
from .channel import ChannelOutput
@@ -47,6 +48,8 @@ class PostOutput(pydantic.BaseModel):
class PlacementPostOutput(pydantic.BaseModel):
id: uuid.UUID
status: PlacementPostStatus
subscriptions_count: int
views_count: int | None
created_at: datetime.datetime
@@ -109,3 +112,7 @@ class DeletePlacementInput(pydantic.BaseModel):
workspace_id: uuid.UUID
project_id: uuid.UUID
placement_id: uuid.UUID
class UpdatePlacementPostInput(pydantic.BaseModel):
status: PlacementPostStatus | None = None

View File

@@ -31,6 +31,7 @@ from .creative.get_creative import get_creative
from .creative.get_creatives import get_creatives
from .creative.update_creative import update_creative
from .placement.fetch_placement_post_cycle import fetch_placement_post_cycle
from .placement.update_post_status_cycle import update_post_status_cycle
from .project.archive_project import archive_project, unarchive_project
from .project.delete_project import delete_project
from .project.disconnect_project_by_tg_id import disconnect_project_by_tg_id
@@ -46,6 +47,7 @@ from .purchase.delete_placement import delete_placement
from .purchase.get_placement import get_placement_user
from .purchase.get_placements import get_placements
from .purchase.update_placement import update_placement
from .purchase.update_placement_post import update_placement_post
from .subscription.handle_subscription import handle_subscription
from .subscription.handle_unsubscription import handle_unsubscription
from .views.get_views_history import get_views_history
@@ -228,6 +230,7 @@ class Usecase:
get_placement_user = get_placement_user
build_placement_creative = build_placement_creative
update_placement = update_placement
update_placement_post = update_placement_post
delete_placement = delete_placement
# Creative use cases
get_creatives = get_creatives
@@ -237,6 +240,7 @@ class Usecase:
delete_creative = delete_creative
# PlacementPost (system-managed) use cases
fetch_placement_post_cycle = fetch_placement_post_cycle
update_post_status_cycle = update_post_status_cycle
handle_subscription = handle_subscription
handle_unsubscription = handle_unsubscription
get_views_history = get_views_history

View File

@@ -0,0 +1,141 @@
import datetime
import logging
from typing import TYPE_CHECKING
from tortoise import timezone
from src import domain
from src.domain.placement_post import PlacementPostStatus
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
# Статусы, которые не должны обновляться автоматически (финальные или ручные)
FINAL_STATUSES = {
PlacementPostStatus.COMPLETED_DELETED,
PlacementPostStatus.COMPLETED_NOT_DELETED,
PlacementPostStatus.CHECK_COMPLETED,
}
# Статусы, при которых нужно проверять условия для автоматического обновления
AUTO_UPDATE_STATUSES = {
PlacementPostStatus.NO_STATUS,
PlacementPostStatus.SEND_POST,
PlacementPostStatus.POST_APPROVAL,
PlacementPostStatus.WAITING_SCHEDULE,
PlacementPostStatus.SCHEDULED,
PlacementPostStatus.POST_PUBLISHED,
PlacementPostStatus.CHECK_DELETED_EARLY,
PlacementPostStatus.CHECK_NOT_PUBLISHED,
}
async def update_post_status_cycle(self: 'Usecase', interval_seconds: int) -> None:
"""Автоматически обновляет статусы PlacementPost на основе состояния постов"""
log.debug('Starting update_post_status_cycle')
# Получаем все PlacementPost со статусами, которые могут быть обновлены
placement_posts = (
await domain.PlacementPost.filter(
status__in=list(AUTO_UPDATE_STATUSES),
)
.prefetch_related('placement', 'post', 'post__channel')
.all()
)
if not placement_posts:
log.debug('No placement_posts to update')
return
updated_count = 0
now = timezone.now()
for placement_post in placement_posts:
placement = placement_post.placement
post = placement_post.post
if not placement:
log.warning('PlacementPost %s missing placement', placement_post.id)
continue
new_status = _determine_status(placement, placement_post, post, now)
if new_status and new_status != placement_post.status:
old_status = placement_post.status
placement_post.status = new_status
await placement_post.save()
updated_count += 1
log.info(
'Updated PlacementPost %s status: %s -> %s',
placement_post.id,
old_status,
new_status,
)
log.debug('Update post status cycle completed. Updated %s placement_posts', updated_count)
def _determine_status(
placement: domain.Placement,
placement_post: domain.PlacementPost,
post: domain.Post | None,
now: datetime.datetime,
) -> PlacementPostStatus | None:
"""Определяет новый статус PlacementPost на основе текущего состояния"""
# Если поста нет и прошло время размещения - "Пост не вышел"
if post is None:
if placement.placement_at and placement.placement_at < now:
return PlacementPostStatus.CHECK_NOT_PUBLISHED
return None
# Если пост есть, но не опубликован - оставляем как есть
if not post.published_at:
return None
# Пост опубликован - проверяем условия для перехода статусов
published_at = post.published_at
deleted_at = post.deleted_from_channel_at
# Вычисляем время в топе
if deleted_at:
time_on_top = int((deleted_at - published_at).total_seconds())
else:
time_on_top = int((now - published_at).total_seconds())
# Получаем требуемую длительность из формата
required_duration = domain.parse_format_duration(placement.format)
# Если формат "без удаления" - размещение отработало, если прошло 24 часа
if required_duration is None:
# По умолчанию считаем что "без удаления" = 24 часа минимум
required_duration = 24 * 3600
# Проверяем условия для автоматических статусов
# Пост удалён раньше срока
if deleted_at and time_on_top < required_duration:
return PlacementPostStatus.CHECK_DELETED_EARLY
# Размещение отработало - пост удалён
if deleted_at and time_on_top >= required_duration:
return PlacementPostStatus.COMPLETED_DELETED
# Размещение отработало - пост не удалён
if not deleted_at and time_on_top >= required_duration:
return PlacementPostStatus.COMPLETED_NOT_DELETED
# Пост вышел, но ещё не отработал
if placement_post.status in {
PlacementPostStatus.NO_STATUS,
PlacementPostStatus.SEND_POST,
PlacementPostStatus.POST_APPROVAL,
PlacementPostStatus.WAITING_SCHEDULE,
PlacementPostStatus.SCHEDULED,
}:
return PlacementPostStatus.POST_PUBLISHED
return None

View File

@@ -42,6 +42,8 @@ def _build_placement_post_output(
return None
return dto.PlacementPostOutput(
id=placement_post.id,
status=placement_post.status,
subscriptions_count=subscriptions_count,
views_count=views_count,
created_at=placement_post.created_at,

View File

@@ -0,0 +1,72 @@
import logging
import uuid
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
# Статусы Placement, при которых статус PlacementPost не может быть изменён
LOCKED_PLACEMENT_STATUSES = {
domain.PlacementStatus.NO_STATUS,
domain.PlacementStatus.WRITE,
domain.PlacementStatus.CANCELED,
domain.PlacementStatus.PRICE_NOT_OK,
domain.PlacementStatus.NOT_RELEVANT,
domain.PlacementStatus.NO_RESPONSE,
}
async def update_placement_post(
self: 'Usecase',
placement_id: uuid.UUID,
placement_post_id: uuid.UUID,
input: dto.UpdatePlacementPostInput,
workspace_id: uuid.UUID,
project_id: uuid.UUID,
user_id: uuid.UUID,
) -> dto.PlacementWithPostsOutput:
context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE)
project = await self.database.get_project(workspace_id, project_id=project_id)
if not project:
raise domain.ProjectNotFound(project_id)
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id)
placement = await self.database.get_placement(workspace_id, placement_id)
if not placement or placement.project_id != project.id:
raise domain.PlacementNotFound(placement_id)
placement_post = await self.database.get_placement_post(workspace_id, placement_post_id)
if not placement_post or placement_post.placement_id != placement.id:
raise domain.PlacementPostNotFound(placement_post_id)
fields_set = input.model_fields_set
if 'status' in fields_set and input.status is not None:
# Проверяем, можно ли менять статус поста при текущем статусе Placement
if placement.status in LOCKED_PLACEMENT_STATUSES:
log.warning(
'Cannot change PlacementPost status when Placement status is %s',
placement.status,
)
# Статус поста должен оставаться "Без статуса"
if input.status != domain.PlacementPostStatus.NO_STATUS:
raise ValueError(
f'Статус поста недоступен для изменения при статусе взаимодействия "{placement.status}"'
)
placement_post.status = input.status
await placement_post.save()
placement_input = dto.GetPlacementInput(
user_id=user_id,
workspace_id=workspace_id,
project_id=project_id,
placement_id=placement.id,
)
return await self.get_placement_user(input=placement_input)