Проект разделен на два репозитория

This commit is contained in:
mi
2026-08-14 15:42:45 +03:00
parent e06a77ee1d
commit bbef7a30c9
521 changed files with 2597 additions and 2302 deletions
@@ -0,0 +1,137 @@
from __future__ import annotations
import ipaddress
import re
from functools import cached_property
from pathlib import Path
from urllib.parse import urlsplit
from pydantic import Field, SecretStr, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
FIELD_RE = re.compile(r"^UF_CRM_[0-9]+$")
MEMBER_RE = re.compile(r"^[A-Za-z0-9_-]{8,128}$")
def _read_secret(value: SecretStr | None, path: Path | None) -> SecretStr | None:
if value and value.get_secret_value():
return value
if path:
text = path.read_text(encoding="utf-8").strip()
return SecretStr(text) if text else None
return None
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="BITRIX_SYNC_", extra="ignore")
enabled: bool = False
mode: str = "disabled"
database_url: SecretStr | None = None
database_url_file: Path | None = None
crm_rest_webhook_url: SecretStr | None = None
crm_rest_webhook_url_file: Path | None = None
contact_receiver_token: SecretStr | None = None
contact_receiver_token_file: Path | None = None
contact_receiver_previous_token: SecretStr | None = None
alert_receiver_token: SecretStr | None = None
alert_receiver_token_file: Path | None = None
alert_receiver_previous_token: SecretStr | None = None
service_token: SecretStr | None = None
service_token_file: Path | None = None
portal_host: str | None = None
portal_member_id: str | None = None
public_base_url: str | None = None
contact_user_id_field: str | None = None
contact_registered_field: str | None = None
contact_citizenship_field: str | None = None
webhook_allowed_cidrs: str = ""
http_timeout_sec: float = Field(default=10, ge=1, le=60)
db_pool_size: int = Field(default=5, ge=1, le=30)
webhook_max_body_bytes: int = Field(default=16_384, ge=1024, le=65_536)
webhook_max_fields: int = Field(default=24, ge=8, le=64)
lease_seconds: int = Field(default=60, ge=10, le=600)
claim_size: int = Field(default=20, ge=1, le=100)
batch_size: int = Field(default=20, ge=1, le=50)
batch_wait_ms: int = Field(default=200, ge=10, le=5000)
limiter_refill_per_sec: float = Field(default=2, gt=0, le=50)
limiter_burst: int = Field(default=2, ge=1, le=50)
max_in_flight: int = Field(default=2, ge=1, le=20)
retry_base_seconds: float = Field(default=1, ge=0.1, le=60)
retry_max_seconds: float = Field(default=900, ge=1, le=3600)
retry_horizon_seconds: int = Field(default=86_400, ge=60, le=604_800)
reconciliation_overlap_seconds: int = Field(default=300, ge=0, le=3600)
reconciliation_interval_seconds: int = Field(default=900, ge=60, le=86_400)
@model_validator(mode="after")
def validate_mode(self) -> Settings:
self.database_url = _read_secret(self.database_url, self.database_url_file)
self.crm_rest_webhook_url = _read_secret(
self.crm_rest_webhook_url, self.crm_rest_webhook_url_file
)
self.contact_receiver_token = _read_secret(
self.contact_receiver_token, self.contact_receiver_token_file
)
self.alert_receiver_token = _read_secret(
self.alert_receiver_token, self.alert_receiver_token_file
)
self.service_token = _read_secret(self.service_token, self.service_token_file)
expected_mode = "full" if self.enabled else "disabled"
if self.mode != expected_mode:
raise ValueError(f"mode must be {expected_mode!r} when enabled={self.enabled}")
if not self.enabled:
return self
required = {
"database_url": self.database_url,
"crm_rest_webhook_url": self.crm_rest_webhook_url,
"contact_receiver_token": self.contact_receiver_token,
"alert_receiver_token": self.alert_receiver_token,
"service_token": self.service_token,
"portal_host": self.portal_host,
"portal_member_id": self.portal_member_id,
"public_base_url": self.public_base_url,
"contact_user_id_field": self.contact_user_id_field,
"contact_registered_field": self.contact_registered_field,
"contact_citizenship_field": self.contact_citizenship_field,
}
missing = [name for name, value in required.items() if not value]
if missing:
raise ValueError("missing full-mode settings: " + ", ".join(missing))
for name in (
"contact_user_id_field",
"contact_registered_field",
"contact_citizenship_field",
):
if not FIELD_RE.fullmatch(str(getattr(self, name))):
raise ValueError(f"{name} must match UF_CRM_<digits>")
if not MEMBER_RE.fullmatch(str(self.portal_member_id)):
raise ValueError("portal_member_id has invalid format")
crm = urlsplit(self.crm_rest_webhook_url.get_secret_value())
public = urlsplit(str(self.public_base_url))
if crm.scheme != "https" or crm.hostname != self.portal_host or crm.port not in (None, 443):
raise ValueError("CRM URL must be HTTPS on the approved portal host")
if public.scheme != "https" or not public.hostname or public.query or public.fragment:
raise ValueError("public_base_url must be a query-free HTTPS origin")
if not self.allowed_networks:
raise ValueError("webhook_allowed_cidrs cannot be empty in full mode")
return self
@cached_property
def allowed_networks(self) -> tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...]:
values = [item.strip() for item in self.webhook_allowed_cidrs.split(",") if item.strip()]
return tuple(ipaddress.ip_network(item, strict=True) for item in values)
@staticmethod
def rest_field_name(field: str) -> str:
if not FIELD_RE.fullmatch(field):
raise ValueError("invalid Bitrix custom field")
return "ufCrm_" + field.removeprefix("UF_CRM_")
def load_settings() -> Settings:
return Settings()