All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 6s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m45s
CI / backend-tests (pull_request) Successful in 14m3s
135 lines
5.2 KiB
Python
135 lines
5.2 KiB
Python
"""Минимальный синхронный DaData-клиент — /clean/address → (lat, lon).
|
||
|
||
Нужен ТОЛЬКО для geo-pass в objective_backfill (#2177): адрес objective-проекта
|
||
→ координаты для haversine-сверки с domrf-точкой. Не тащим tradein-модуль
|
||
(`tradein-mvp/.../dadata.py`) импортом — это другое приложение с другим settings.
|
||
|
||
Клиент СИНХРОННЫЙ (httpx.Client): geo-pass гоняется в CLI / sync-Session, без
|
||
event-loop. Авторизация /clean/address требует ОБА: token + secret (в отличие от
|
||
/suggest, которому хватает токена). Оба берутся из settings (env DADATA_API_TOKEN /
|
||
DADATA_API_SECRET).
|
||
|
||
Возвращает None (не raise) на любой сбой — нет credentials, пустой/короткий адрес,
|
||
сетевая/HTTP-ошибка, 429 quota, невалидный JSON, адрес не распознан. Вызывающая
|
||
сторона (geo-pass) трактует None как «нет геокода» → reject-кандидат, проход не
|
||
падает.
|
||
|
||
Docs: https://dadata.ru/api/clean/address/
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Any
|
||
|
||
import httpx
|
||
|
||
from app.core.config import settings
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
DADATA_CLEAN_URL = "https://cleaner.dadata.ru/api/v1/clean/address"
|
||
|
||
|
||
def _credentials() -> tuple[str, str] | None:
|
||
"""(token, secret) если ОБА заданы непусто, иначе None (graceful disable)."""
|
||
token = (settings.dadata_api_token or "").strip()
|
||
secret = (settings.dadata_api_secret or "").strip()
|
||
if not token or not secret:
|
||
return None
|
||
return token, secret
|
||
|
||
|
||
def _coerce_float(value: Any) -> float | None:
|
||
"""DaData отдаёт geo_lat/geo_lon строкой — coerce в float безопасно."""
|
||
if value is None:
|
||
return None
|
||
try:
|
||
return float(value)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def clean_address(address: str) -> tuple[float, float] | None:
|
||
"""Геокодировать один адрес через DaData /clean/address → (lat, lon).
|
||
|
||
Args:
|
||
address: свободный текст адреса (objective_lots.address).
|
||
|
||
Returns:
|
||
(geo_lat, geo_lon) как float-пара, либо None если:
|
||
- credentials не заданы в ENV (graceful disable);
|
||
- адрес пустой / короче 3 символов;
|
||
- сетевая / HTTP-ошибка (timeout, 5xx, 429 quota, 401/403 auth, 4xx);
|
||
- невалидный JSON / пустой массив / не dict;
|
||
- DaData не вернул geo_lat/geo_lon (адрес не геокодирован).
|
||
"""
|
||
if not address or len(address.strip()) < 3:
|
||
return None
|
||
|
||
creds = _credentials()
|
||
if creds is None:
|
||
logger.debug("dadata_client: credentials не заданы — skip геокод")
|
||
return None
|
||
|
||
token, secret = creds
|
||
headers = {
|
||
"Authorization": f"Token {token}",
|
||
"X-Secret": secret,
|
||
"Content-Type": "application/json",
|
||
"Accept": "application/json",
|
||
}
|
||
body = [address.strip()]
|
||
|
||
try:
|
||
with httpx.Client(timeout=settings.dadata_timeout_s) as client:
|
||
response = client.post(DADATA_CLEAN_URL, headers=headers, json=body)
|
||
except (httpx.TimeoutException, httpx.NetworkError) as exc:
|
||
logger.warning("dadata_client: network error для %r: %s", address[:60], exc)
|
||
return None
|
||
except Exception as exc:
|
||
# defensive: любой прочий httpx/клиент-сбой → None, geo-pass reject'ит,
|
||
# НЕ роняем весь проход. Логируем на warning (не глотаем молча).
|
||
logger.warning("dadata_client: unexpected client error для %r: %s", address[:60], exc)
|
||
return None
|
||
|
||
status = response.status_code
|
||
if status == 429:
|
||
logger.warning("dadata_client: HTTP 429 — quota exceeded")
|
||
return None
|
||
if status in (401, 403):
|
||
logger.error(
|
||
"dadata_client: HTTP %d — auth rejected. Проверь DADATA_API_TOKEN/SECRET.",
|
||
status,
|
||
)
|
||
return None
|
||
if status >= 400:
|
||
logger.warning("dadata_client: HTTP %d для %r", status, address[:60])
|
||
return None
|
||
|
||
try:
|
||
payload = response.json()
|
||
except ValueError as exc:
|
||
logger.warning("dadata_client: невалидный JSON: %s", exc)
|
||
return None
|
||
|
||
if not isinstance(payload, list) or not payload:
|
||
logger.info("dadata_client: пустой ответ для %r", address[:60])
|
||
return None
|
||
|
||
item = payload[0]
|
||
if not isinstance(item, dict):
|
||
logger.warning("dadata_client: первый элемент не dict (%s)", type(item).__name__)
|
||
return None
|
||
|
||
lat = _coerce_float(item.get("geo_lat"))
|
||
lon = _coerce_float(item.get("geo_lon"))
|
||
if lat is None or lon is None:
|
||
logger.info(
|
||
"dadata_client: нет координат (qc_geo=%s) для %r",
|
||
item.get("qc_geo"),
|
||
address[:60],
|
||
)
|
||
return None
|
||
|
||
return (lat, lon)
|