fix: ввод даты, ввод формата

This commit is contained in:
Artem Tsyrulnikov
2026-02-28 13:06:30 +03:00
parent 0ad9fe36a6
commit c9d5f1e2ea
14 changed files with 1012 additions and 212 deletions

View File

@@ -20,6 +20,10 @@ __all__ = (
'PlacementStatus',
'PlacementType',
'parse_format_duration',
'parse_format_string',
'format_display_string',
'get_feed_duration_seconds',
'COMMON_FORMATS',
'PlacementPost',
'PlacementPostStatus',
'Creative',
@@ -116,12 +120,16 @@ from .error import (
)
from .login_token import LoginToken
from .placement import (
COMMON_FORMATS,
CostType,
InviteLinkType,
Placement,
PlacementStatus,
PlacementType,
format_display_string,
get_feed_duration_seconds,
parse_format_duration,
parse_format_string,
)
from .placement_post import PlacementPost, PlacementPostStatus
from .post import Post

View File

@@ -12,7 +12,18 @@ if TYPE_CHECKING:
from .creative import Creative
from .project import Project
__all__ = ['Placement', 'PlacementStatus', 'PlacementType', 'InviteLinkType', 'CostType', 'parse_format_duration']
__all__ = [
'Placement',
'PlacementStatus',
'PlacementType',
'InviteLinkType',
'CostType',
'parse_format_duration',
'parse_format_string',
'format_display_string',
'get_feed_duration_seconds',
'COMMON_FORMATS',
]
class CostType(enum.StrEnum):
@@ -43,9 +54,141 @@ class InviteLinkType(enum.StrEnum):
APPROVAL = 'approval' # с одобрением ботом
def parse_format_string(format_str: str | None) -> tuple[int | None, int | None]:
"""
Парсит строку формата размещения в числовые значения (минуты).
Разделяет строку по '/' на 2 части (top, feed).
Top: число <= 12 → часы (*60), > 12 → минуты (для миграции старых данных).
Feed: "без удаления" → 0; ""/"X дней" → дни (*24*60); число → часы (*60).
Returns:
(top_time_minutes, feed_time_minutes) — (None, None) если не распознано
"""
if not format_str:
return None, None
format_str = format_str.strip().lower()
if not format_str:
return None, None
# Проверяем наличие разделителя
if '/' not in format_str:
return None, None
parts = format_str.split('/', 1)
if len(parts) != 2:
return None, None
top_str = parts[0].strip().strip('(').strip(')')
feed_str = parts[1].strip().strip('(').strip(')')
# Парсим top
top_minutes = _parse_top_part(top_str)
if top_minutes is None:
return None, None
# Парсим feed
feed_minutes = _parse_feed_part(feed_str)
if feed_minutes is None:
return None, None
return top_minutes, feed_minutes
def _parse_top_part(s: str) -> int | None:
"""Парсит часть top из строки формата."""
s = s.strip()
# Убираем суффиксы единиц
s = re.sub(r'\s*(ч|час|часов|часа|мин|минут|минуты)\s*$', '', s)
s = s.strip()
match = re.match(r'^(\d+)$', s)
if not match:
return None
value = int(match.group(1))
if value <= 0:
return None
# <= 12 → часы, > 12 → минуты (для обратной совместимости со старыми данными)
if value <= 12:
return value * 60
return value
def _parse_feed_part(s: str) -> int | None:
"""Парсит часть feed из строки формата. Возвращает минуты, 0 = без удаления."""
s = s.strip()
# "без удаления"
if 'без удаления' in s:
return 0
# Ищем дни: "7д", "7 дней", "7 дн", "(7 дней)"
days_match = re.search(r'(\d+)\s*(?:д(?:н|ней|ня)?)', s)
if days_match:
days = int(days_match.group(1))
if days <= 0:
return None
return days * 24 * 60
# Ищем часы: "24ч", "24 часов", или просто число
s = re.sub(r'\s*(ч|час|часов|часа|мин|минут|минуты)\s*$', '', s)
s = s.strip().strip('(').strip(')')
match = re.match(r'^(\d+)$', s)
if not match:
return None
value = int(match.group(1))
if value <= 0:
return None
# Значение — часы, конвертируем в минуты
return value * 60
def format_display_string(top_minutes: int | None, feed_minutes: int | None) -> str | None:
"""
Формирует строку отображения из числовых значений.
Returns:
Строка вида "1ч / 24ч", "30мин / 7д", "1ч / без удаления", или None
"""
if top_minutes is None and feed_minutes is None:
return None
top_str = _format_duration_top(top_minutes) if top_minutes is not None else '?'
feed_str = _format_duration_feed(feed_minutes) if feed_minutes is not None else '?'
return f'{top_str} / {feed_str}'
def _format_duration_top(minutes: int) -> str:
"""Форматирует время топа: кратно 60 → '', иначе → 'Xмин'."""
if minutes > 0 and minutes % 60 == 0:
return f'{minutes // 60}ч'
return f'{minutes}мин'
def _format_duration_feed(minutes: int) -> str:
"""Форматирует время ленты: 0 → 'без удаления', ≥7д и кратно дню → '', кратно 60 → '', иначе → 'Xмин'."""
if minutes == 0:
return 'без удаления'
# Дни используем только для >= 7 дней (10080 мин), иначе часы (24ч, 48ч, 72ч выглядят привычнее)
if minutes >= 7 * 24 * 60 and minutes % (24 * 60) == 0:
return f'{minutes // (24 * 60)}д'
if minutes % 60 == 0:
return f'{minutes // 60}ч'
return f'{minutes}мин'
def parse_format_duration(format_str: str | None) -> int | None:
"""
Парсит формат размещения и возвращает длительность в секундах.
Парсит формат размещения и возвращает длительность ленты в секундах.
Обёртка для обратной совместимости.
Примеры форматов:
- "1 / 24" -> 86400 сек (24 часа)
@@ -59,28 +202,51 @@ def parse_format_duration(format_str: str | None) -> int | None:
Returns:
int | None: Длительность в секундах или None для "без удаления"
"""
if not format_str:
_, feed_minutes = parse_format_string(format_str)
if feed_minutes is None or feed_minutes == 0:
return None
return feed_minutes * 60
format_str = format_str.strip().lower()
# Проверяем "без удаления"
if 'без удаления' in format_str:
return None
def get_feed_duration_seconds(placement: 'Placement') -> int | None:
"""
Возвращает длительность ленты в секундах для placement.
# Ищем число часов (например "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 # конвертируем в секунды
Сначала проверяет числовые поля (feed_time_minutes),
потом fallback на parse_format_duration(placement.format).
0 = без удаления → None.
# Ищем дни (например "(7 дней)", "(30 дней)")
days_match = re.search(r'\((\d+)\s*дн', format_str)
if days_match:
days = int(days_match.group(1))
return days * 24 * 3600 # конвертируем в секунды
Returns:
int | None: Длительность в секундах или None
"""
if placement.feed_time_minutes is not None:
if placement.feed_time_minutes == 0:
return None
return placement.feed_time_minutes * 60
return None
return parse_format_duration(placement.format)
COMMON_FORMATS: list[dict[str, int | str]] = [
{'top_time_minutes': 60, 'feed_time_minutes': 1440, 'label': '1ч / 24ч'},
{'top_time_minutes': 60, 'feed_time_minutes': 2160, 'label': '1ч / 36ч'},
{'top_time_minutes': 60, 'feed_time_minutes': 2880, 'label': '1ч / 48ч'},
{'top_time_minutes': 60, 'feed_time_minutes': 4320, 'label': '1ч / 72ч'},
{'top_time_minutes': 60, 'feed_time_minutes': 10080, 'label': '1ч / 7д'},
{'top_time_minutes': 60, 'feed_time_minutes': 43200, 'label': '1ч / 30д'},
{'top_time_minutes': 60, 'feed_time_minutes': 86400, 'label': '1ч / 60д'},
{'top_time_minutes': 60, 'feed_time_minutes': 129600, 'label': '1ч / 90д'},
{'top_time_minutes': 60, 'feed_time_minutes': 0, 'label': '1ч / без удаления'},
{'top_time_minutes': 120, 'feed_time_minutes': 1440, 'label': '2ч / 24ч'},
{'top_time_minutes': 120, 'feed_time_minutes': 2160, 'label': '2ч / 36ч'},
{'top_time_minutes': 120, 'feed_time_minutes': 2880, 'label': '2ч / 48ч'},
{'top_time_minutes': 120, 'feed_time_minutes': 4320, 'label': '2ч / 72ч'},
{'top_time_minutes': 120, 'feed_time_minutes': 10080, 'label': '2ч / 7д'},
{'top_time_minutes': 120, 'feed_time_minutes': 43200, 'label': '2ч / 30д'},
{'top_time_minutes': 120, 'feed_time_minutes': 86400, 'label': '2ч / 60д'},
{'top_time_minutes': 120, 'feed_time_minutes': 129600, 'label': '2ч / 90д'},
{'top_time_minutes': 120, 'feed_time_minutes': 0, 'label': '2ч / без удаления'},
]
class Placement(TimestampedModel):
@@ -93,6 +259,8 @@ class Placement(TimestampedModel):
cost_before_bargain = fields.FloatField(null=True)
placement_type = fields.CharEnumField(PlacementType, null=True, max_length=16)
format = fields.TextField(null=True)
top_time_minutes = fields.IntField(null=True)
feed_time_minutes = fields.IntField(null=True)
comment = fields.TextField(null=True)
invite_link = fields.CharField(max_length=512, null=True)

View File

@@ -22,6 +22,8 @@ class PlacementDetails(pydantic.BaseModel):
cost_before_bargain: CostInfo | None = None
placement_type: PlacementType | None = None
format: str | None = None
top_time_minutes: int | None = None
feed_time_minutes: int | None = None
comment: str | None = None
creative_id: uuid.UUID | None = None
invite_link_type: InviteLinkType | None = None
@@ -110,6 +112,8 @@ class UpdatePlacementInput(pydantic.BaseModel):
cost_before_bargain: CostInfo | None = None
placement_type: PlacementType | None = None
format: str | None = None
top_time_minutes: int | None = None
feed_time_minutes: int | None = None
class DeletePlacementInput(pydantic.BaseModel):

View File

@@ -107,7 +107,7 @@ def _determine_status(
time_on_top = int((now - published_at).total_seconds())
# Получаем требуемую длительность из формата
required_duration = domain.parse_format_duration(placement.format)
required_duration = domain.get_feed_duration_seconds(placement)
# Если формат "без удаления" - размещение отработало, если прошло 24 часа
if required_duration is None:

View File

@@ -109,8 +109,11 @@ def _build_info_message(
datetime_str = placement.placement_at.strftime('%d.%m.%Y %H:%M')
lines.append(f'Дата и время: {datetime_str}')
# Format (optional)
if placement.format:
# Format (optional) — prefer display string from numeric fields
display_format = domain.format_display_string(placement.top_time_minutes, placement.feed_time_minutes)
if display_format:
lines.append(f'Формат: {display_format}')
elif placement.format:
lines.append(f'Формат: {placement.format}')
# Add empty line before footer

View File

@@ -94,6 +94,8 @@ def _build_placement_details(placement: domain.Placement) -> dto.PlacementDetail
cost_before_bargain=_build_cost_info(placement.cost_before_bargain_type, placement.cost_before_bargain),
placement_type=placement.placement_type,
format=placement.format,
top_time_minutes=placement.top_time_minutes,
feed_time_minutes=placement.feed_time_minutes,
comment=placement.comment,
)
if details.model_dump(exclude_none=True):
@@ -101,6 +103,30 @@ def _build_placement_details(placement: domain.Placement) -> dto.PlacementDetail
return None
def _sync_format_fields(
format_str: str | None,
top_time_minutes: int | None,
feed_time_minutes: int | None,
) -> tuple[str | None, int | None, int | None]:
"""Синхронизирует format string и числовые поля.
Если есть числовые поля — вычисляем display string.
Если только format string — парсим числовые поля.
"""
if top_time_minutes is not None or feed_time_minutes is not None:
display = domain.format_display_string(top_time_minutes, feed_time_minutes)
if display:
format_str = display
elif format_str:
parsed_top, parsed_feed = domain.parse_format_string(format_str)
if parsed_top is not None:
top_time_minutes = parsed_top
if parsed_feed is not None:
feed_time_minutes = parsed_feed
return format_str, top_time_minutes, feed_time_minutes
def _build_placement_output(
placement: domain.Placement,
project: domain.Project | None = None,
@@ -201,6 +227,11 @@ async def create_placements(
raise domain.CreativeNotFound(creative_id)
creatives_by_id[creative_id] = creative
raw_format = channel_details.format if channel_details else None
raw_top = channel_details.top_time_minutes if channel_details else None
raw_feed = channel_details.feed_time_minutes if channel_details else None
synced_format, synced_top, synced_feed = _sync_format_fields(raw_format, raw_top, raw_feed)
placement = domain.Placement(
project_id=project.id,
creative_id=creative.id if creative else None,
@@ -227,7 +258,9 @@ async def create_placements(
else None
),
placement_type=channel_details.placement_type if channel_details else None,
format=channel_details.format if channel_details else None,
format=synced_format,
top_time_minutes=synced_top,
feed_time_minutes=synced_feed,
)
await self.database.create_placement(placement)

View File

@@ -69,8 +69,28 @@ async def update_placement(
placement.cost_before_bargain = input.cost_before_bargain.value
if 'placement_type' in fields_set:
placement.placement_type = input.placement_type
if 'format' in fields_set:
# Синхронизация format / top_time_minutes / feed_time_minutes
format_changed = 'format' in fields_set
top_changed = 'top_time_minutes' in fields_set
feed_changed = 'feed_time_minutes' in fields_set
if format_changed:
placement.format = input.format
if top_changed:
placement.top_time_minutes = input.top_time_minutes
if feed_changed:
placement.feed_time_minutes = input.feed_time_minutes
if format_changed or top_changed or feed_changed:
from .create_placements import _sync_format_fields
synced_format, synced_top, synced_feed = _sync_format_fields(
placement.format, placement.top_time_minutes, placement.feed_time_minutes
)
placement.format = synced_format
placement.top_time_minutes = synced_top
placement.feed_time_minutes = synced_feed
await self.database.update_placement(placement)