fix: ввод даты, ввод формата
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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д"/"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ч', иначе → 'Xмин'."""
|
||||
if minutes > 0 and minutes % 60 == 0:
|
||||
return f'{minutes // 60}ч'
|
||||
return f'{minutes}мин'
|
||||
|
||||
|
||||
def _format_duration_feed(minutes: int) -> str:
|
||||
"""Форматирует время ленты: 0 → 'без удаления', ≥7д и кратно дню → 'Xд', кратно 60 → 'Xч', иначе → '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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user