статусы
This commit is contained in:
@@ -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";
|
||||
"""
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
141
src/usecase/placement/update_post_status_cycle.py
Normal file
141
src/usecase/placement/update_post_status_cycle.py
Normal 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
|
||||
@@ -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,
|
||||
|
||||
72
src/usecase/purchase/update_placement_post.py
Normal file
72
src/usecase/purchase/update_placement_post.py
Normal 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)
|
||||
Reference in New Issue
Block a user