107 lines
3.1 KiB
Python
107 lines
3.1 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import random
|
|
import re
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime
|
|
from enum import StrEnum
|
|
from typing import Any
|
|
|
|
PHONE_RE = re.compile(r"^\+7[0-9]{10}$")
|
|
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
|
|
|
|
class WorkflowState(StrEnum):
|
|
CREATED = "created"
|
|
RUNNING = "running"
|
|
WAITING_CRM = "waiting_crm"
|
|
WAITING_RETRY = "waiting_retry"
|
|
WAITING_MANUAL = "waiting_manual"
|
|
SUCCEEDED = "succeeded"
|
|
FAILED = "failed"
|
|
CANCELLED = "cancelled"
|
|
|
|
|
|
TRANSITIONS: dict[WorkflowState, frozenset[WorkflowState]] = {
|
|
WorkflowState.CREATED: frozenset({WorkflowState.RUNNING, WorkflowState.CANCELLED}),
|
|
WorkflowState.RUNNING: frozenset(
|
|
{
|
|
WorkflowState.WAITING_CRM,
|
|
WorkflowState.WAITING_RETRY,
|
|
WorkflowState.WAITING_MANUAL,
|
|
WorkflowState.SUCCEEDED,
|
|
WorkflowState.FAILED,
|
|
WorkflowState.CANCELLED,
|
|
}
|
|
),
|
|
WorkflowState.WAITING_CRM: frozenset(
|
|
{
|
|
WorkflowState.RUNNING,
|
|
WorkflowState.WAITING_RETRY,
|
|
WorkflowState.WAITING_MANUAL,
|
|
WorkflowState.FAILED,
|
|
}
|
|
),
|
|
WorkflowState.WAITING_RETRY: frozenset(
|
|
{WorkflowState.RUNNING, WorkflowState.WAITING_MANUAL, WorkflowState.FAILED}
|
|
),
|
|
WorkflowState.WAITING_MANUAL: frozenset(
|
|
{WorkflowState.RUNNING, WorkflowState.CANCELLED}
|
|
),
|
|
WorkflowState.SUCCEEDED: frozenset(),
|
|
WorkflowState.FAILED: frozenset(),
|
|
WorkflowState.CANCELLED: frozenset(),
|
|
}
|
|
|
|
|
|
def assert_transition(current: WorkflowState, target: WorkflowState) -> None:
|
|
if target not in TRANSITIONS[current]:
|
|
raise ValueError(f"forbidden workflow transition {current} -> {target}")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ContactCandidate:
|
|
b24_id: str
|
|
created_at: datetime
|
|
crm_user_id: str | None
|
|
|
|
|
|
def validate_phone(value: str) -> str:
|
|
if not PHONE_RE.fullmatch(value):
|
|
raise ValueError("phone must be Russian E.164 +7XXXXXXXXXX")
|
|
return value
|
|
|
|
|
|
def choose_newest(candidates: list[ContactCandidate]) -> ContactCandidate | None:
|
|
if not candidates:
|
|
return None
|
|
return max(candidates, key=lambda item: (item.created_at, int(item.b24_id)))
|
|
|
|
|
|
def select_email(items: list[dict[str, Any]]) -> str | None:
|
|
valid = [
|
|
item
|
|
for item in items
|
|
if isinstance(item.get("VALUE"), str) and EMAIL_RE.fullmatch(item["VALUE"])
|
|
]
|
|
work = next((item for item in valid if item.get("VALUE_TYPE") == "WORK"), None)
|
|
selected = work or (valid[0] if valid else None)
|
|
return selected["VALUE"] if selected else None
|
|
|
|
|
|
def safe_hash(value: str | None) -> str | None:
|
|
return hashlib.sha256(value.encode()).hexdigest() if value is not None else None
|
|
|
|
|
|
def full_jitter_delay(
|
|
attempt: int, base: float, maximum: float, *, rng: random.Random | None = None
|
|
) -> float:
|
|
ceiling = min(maximum, base * (2 ** max(0, attempt - 1)))
|
|
return (rng or random.SystemRandom()).uniform(0, ceiling)
|
|
|
|
|
|
def parse_crm_datetime(value: str) -> datetime:
|
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
return parsed.astimezone(UTC)
|