Files
raynet/app/raynet_client.py
T
JiriUhlir 28596fe772 first
2026-06-18 14:49:29 +02:00

263 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Robustní konektor pro RAYNET CRM API (v2).
RAYNET API používá HTTP Basic Auth (username = email uživatele, password = API
klíč) a navíc povinnou hlavičku ``X-Instance-Name`` určující instanci CRM.
Tato třída je stateless wrapper nad ``requests.Session`` jednu instanci
``RaynetClient`` je možné držet po dobu života aplikace a sdílet pro více volání.
POZOR (viz AGENTS.md): API klíč ani email se nikdy nesmí logovat. Logujeme pouze
URL, HTTP status a (zkrácené) tělo odpovědi při chybě.
"""
import logging
from typing import Any, Optional
import requests
from requests.adapters import HTTPAdapter
from requests.auth import HTTPBasicAuth
from urllib3.util.retry import Retry
logger = logging.getLogger(__name__)
# Výchozí produkční base URL RAYNET CRM API v2.
DEFAULT_BASE_URL = "https://app.raynet.cz/api/v2"
DEFAULT_TIMEOUT = 30 # sekundy
class RaynetError(Exception):
"""Základní výjimka konektoru.
Atributy:
message: lidsky čitelný popis chyby.
status_code: HTTP status (pokud byla obdržena odpověď serveru).
payload: parsované tělo odpovědi (pokud je k dispozici).
"""
def __init__(
self,
message: str,
status_code: Optional[int] = None,
payload: Any = None,
) -> None:
super().__init__(message)
self.message = message
self.status_code = status_code
self.payload = payload
class RaynetAuthError(RaynetError):
"""Chyba autentizace / autorizace (HTTP 401 nebo 403)."""
class RaynetValidationError(RaynetError):
"""Server odmítl data (HTTP 4xx, typicky 400 nevalidní vstup)."""
class RaynetClient:
"""Klient pro komunikaci s RAYNET CRM API.
Příklad použití::
client = RaynetClient(
api_key="...", # API klíč z RAYNET (Nastavení > API)
email="user@firma.cz", # email uživatele
instance_name="moje-instance",
)
company = client.create_company({"name": "ACME s.r.o."})
print(company["id"])
"""
def __init__(
self,
api_key: str,
email: str,
instance_name: str,
base_url: str = DEFAULT_BASE_URL,
timeout: int = DEFAULT_TIMEOUT,
max_retries: int = 3,
) -> None:
if not api_key or not email or not instance_name:
raise ValueError(
"RaynetClient vyžaduje neprázdné api_key, email a instance_name."
)
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self._session = requests.Session()
self._session.auth = HTTPBasicAuth(email, api_key)
self._session.headers.update(
{
"X-Instance-Name": instance_name,
"Content-Type": "application/json",
"Accept": "application/json",
}
)
# Automatické opakování pro přechodné chyby (síť, 429, 5xx).
retry = Retry(
total=max_retries,
backoff_factor=0.5,
status_forcelist=(429, 500, 502, 503, 504),
allowed_methods=frozenset({"GET", "POST", "PUT", "DELETE"}),
raise_on_status=False,
)
adapter = HTTPAdapter(max_retries=retry)
self._session.mount("https://", adapter)
self._session.mount("http://", adapter)
# Bez tajemství logujeme jen instanci a base URL.
logger.info(
"RaynetClient inicializován (instance=%s, base_url=%s)",
instance_name,
self.base_url,
)
# ------------------------------------------------------------------ #
# Veřejné metody
# ------------------------------------------------------------------ #
def create_company(self, data: dict) -> dict:
"""Vytvoří firmu (company) v RAYNET CRM.
RAYNET používá pro vytvoření záznamu HTTP metodu ``PUT`` na kolekci
``/company/`` (nikoli POST).
Args:
data: tělo požadavku dle RAYNET dokumentace. Povinná pole jsou
``name``, ``rating``, ``state`` a ``role``. Příklad kompletní
struktury viz :data:`EXAMPLE_COMPANY` níže.
Returns:
Parsovaná JSON odpověď serveru (obsahuje mj. ``id`` nové firmy).
Raises:
ValueError: pokud ``data`` nejsou slovník nebo chybí povinná pole.
RaynetAuthError: při chybě autentizace (401/403).
RaynetValidationError: při zamítnutí dat serverem (4xx).
RaynetError: při ostatních chybách (síť, 5xx, nevalidní JSON).
"""
if not isinstance(data, dict):
raise ValueError("Parametr 'data' musí být slovník (dict).")
missing = [f for f in ("name", "rating", "state", "role") if not data.get(f)]
if missing:
raise ValueError(
"Firma musí mít vyplněná povinná pole: " + ", ".join(missing) + "."
)
return self._request("PUT", "/company/", json=data)
# ------------------------------------------------------------------ #
# Interní HTTP vrstva
# ------------------------------------------------------------------ #
def _request(self, method: str, path: str, **kwargs: Any) -> dict:
url = f"{self.base_url}/{path.lstrip('/')}"
kwargs.setdefault("timeout", self.timeout)
try:
response = self._session.request(method, url, **kwargs)
except requests.exceptions.Timeout as exc:
logger.error("RAYNET timeout: %s %s", method, url)
raise RaynetError(f"Vypršel časový limit požadavku na {url}.") from exc
except requests.exceptions.ConnectionError as exc:
logger.error("RAYNET connection error: %s %s", method, url)
raise RaynetError(f"Nepodařilo se připojit k {url}.") from exc
except requests.exceptions.RequestException as exc:
# Síťová / nízkoúrovňová chyba requests, kterou jinak neumíme zařadit.
logger.error("RAYNET request error: %s %s %s", method, url, exc)
raise RaynetError(f"Chyba požadavku na {url}: {exc}") from exc
return self._handle_response(response, method, url)
def _handle_response(
self, response: requests.Response, method: str, url: str
) -> dict:
# Pokus o parsování těla (i chybové odpovědi bývají JSON).
try:
payload: Any = response.json() if response.content else {}
except ValueError:
payload = {"raw": response.text}
if response.ok:
logger.info("RAYNET %s %s -> %s", method, url, response.status_code)
return payload if isinstance(payload, dict) else {"data": payload}
# Chybové stavy zalogujeme bez tajemství (auth je v session, ne v logu).
snippet = str(payload)[:500]
logger.error(
"RAYNET %s %s selhalo se statusem %s: %s",
method,
url,
response.status_code,
snippet,
)
if response.status_code in (401, 403):
raise RaynetAuthError(
"Autentizace k RAYNET selhala zkontroluj email, API klíč "
"a název instance.",
status_code=response.status_code,
payload=payload,
)
if 400 <= response.status_code < 500:
raise RaynetValidationError(
f"RAYNET odmítl požadavek (HTTP {response.status_code}).",
status_code=response.status_code,
payload=payload,
)
raise RaynetError(
f"RAYNET vrátil chybu serveru (HTTP {response.status_code}).",
status_code=response.status_code,
payload=payload,
)
def close(self) -> None:
"""Uvolní HTTP session."""
self._session.close()
def __enter__(self) -> "RaynetClient":
return self
def __exit__(self, *exc: Any) -> None:
self.close()
# --------------------------------------------------------------------------- #
# Ukázková struktura požadavku pro create_company (RAYNET CRM API v2,
# model CompanyInsertDto, endpoint PUT /company/).
#
# Povinná pole: name, rating, state, role. Ostatní jsou volitelná.
# Číselníkové hodnoty (rating/state/role/taxPayer) odpovídají enumům RAYNET.
# Pole typu integer (owner, category, legalForm, territory, ...) jsou ID
# odkazující na číselníky ve vaší instanci uveď jen pokud je znáš.
# --------------------------------------------------------------------------- #
EXAMPLE_COMPANY = {
"name": "ACME s.r.o.",
"rating": "A", # A | B | C
"state": "A_POTENTIAL", # A_POTENTIAL | B_ACTUAL | C_DEFERRED | D_UNATTRACTIVE
"role": "A_SUBSCRIBER", # A_SUBSCRIBER | B_PARTNER | C_SUPPLIER | D_RIVAL
"regNumber": "12345678", # IČO
"taxNumber": "CZ12345678", # DIČ
"taxPayer": "YES", # YES | NO
"notice": "Vytvořeno přes konektor.",
"addresses": [
{
"address": {
"name": "Sídlo klienta",
"street": "Francouzská 6167/5",
"city": "Ostrava",
"province": "Morava",
"zipCode": "708 00",
"country": "CZ",
},
"contactInfo": {
"email": "info@acme.cz",
"tel1": "+420 553 401 520",
"tel1Type": "recepce",
"www": "www.acme.cz",
},
}
],
"tags": ["import", "konektor"],
}