diff --git a/migrations/models/14_20260128140817_add_placement_post_status.py b/migrations/models/14_20260128140817_add_placement_post_status.py new file mode 100644 index 0000000..d77059a --- /dev/null +++ b/migrations/models/14_20260128140817_add_placement_post_status.py @@ -0,0 +1,17 @@ +# ruff: noqa +# mypy: ignore-errors +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "placement_post" ADD "status" VARCHAR(64) NOT NULL DEFAULT 'Без статуса'; + """ + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "placement_post" DROP COLUMN "status"; + """ diff --git a/src/controller/http_v1/purchases.py b/src/controller/http_v1/purchases.py index 4d6ee6d..26620ef 100644 --- a/src/controller/http_v1/purchases.py +++ b/src/controller/http_v1/purchases.py @@ -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, diff --git a/src/domain/__init__.py b/src/domain/__init__.py index 56f6d7b..8fe2ba2 100644 --- a/src/domain/__init__.py +++ b/src/domain/__init__.py @@ -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 diff --git a/src/domain/placement.py b/src/domain/placement.py index c4ab37c..0ec49f4 100644 --- a/src/domain/placement.py +++ b/src/domain/placement.py @@ -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) diff --git a/src/domain/placement_post.py b/src/domain/placement_post.py index 3aad2de..a613552 100644 --- a/src/domain/placement_post.py +++ b/src/domain/placement_post.py @@ -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 diff --git a/src/dto/__init__.py b/src/dto/__init__.py index efa9b87..80be20c 100644 --- a/src/dto/__init__.py +++ b/src/dto/__init__.py @@ -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 diff --git a/src/dto/purchase.py b/src/dto/purchase.py index eae6708..f260816 100644 --- a/src/dto/purchase.py +++ b/src/dto/purchase.py @@ -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 diff --git a/src/usecase/__init__.py b/src/usecase/__init__.py index cf8da6c..eea8fb3 100644 --- a/src/usecase/__init__.py +++ b/src/usecase/__init__.py @@ -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 diff --git a/src/usecase/placement/update_post_status_cycle.py b/src/usecase/placement/update_post_status_cycle.py new file mode 100644 index 0000000..ed1e28f --- /dev/null +++ b/src/usecase/placement/update_post_status_cycle.py @@ -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 diff --git a/src/usecase/purchase/get_placement.py b/src/usecase/purchase/get_placement.py index f22333c..9944401 100644 --- a/src/usecase/purchase/get_placement.py +++ b/src/usecase/purchase/get_placement.py @@ -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, diff --git a/src/usecase/purchase/update_placement_post.py b/src/usecase/purchase/update_placement_post.py new file mode 100644 index 0000000..3ca457d --- /dev/null +++ b/src/usecase/purchase/update_placement_post.py @@ -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)