321 lines
12 KiB
Python
321 lines
12 KiB
Python
"""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,
|
||
)
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Generické CRUD nad libovolnou RAYNET entitou (resource)
|
||
#
|
||
# RAYNET má napříč celým API jednotné konvence:
|
||
# list GET /{resource}/
|
||
# detail GET /{resource}/{id}/
|
||
# create PUT /{resource}/
|
||
# update POST /{resource}/{id}/
|
||
# delete DELETE /{resource}/{id}/
|
||
# Tyto metody proto pokrývají celé RAYNET API (company, person, lead,
|
||
# businessCase, activity, product, offer, order, project, ...).
|
||
# ------------------------------------------------------------------ #
|
||
def list_records(self, resource: str, params: Optional[dict] = None) -> dict:
|
||
"""Vrátí seznam záznamů dané entity.
|
||
|
||
Args:
|
||
resource: název entity, např. ``"company"``, ``"person"``, ``"lead"``.
|
||
params: filtry / stránkování (``offset``, ``limit``, ``fulltext``, ...).
|
||
"""
|
||
return self._request("GET", f"/{self._res(resource)}/", params=params)
|
||
|
||
def get_record(self, resource: str, record_id: int | str) -> dict:
|
||
"""Vrátí detail jednoho záznamu."""
|
||
return self._request("GET", f"/{self._res(resource)}/{record_id}/")
|
||
|
||
def create_record(self, resource: str, data: dict) -> dict:
|
||
"""Vytvoří záznam (RAYNET používá pro create metodu PUT)."""
|
||
if not isinstance(data, dict):
|
||
raise ValueError("Parametr 'data' musí být slovník (dict).")
|
||
return self._request("PUT", f"/{self._res(resource)}/", json=data)
|
||
|
||
def update_record(self, resource: str, record_id: int | str, data: dict) -> dict:
|
||
"""Upraví záznam (RAYNET používá pro update metodu POST)."""
|
||
if not isinstance(data, dict):
|
||
raise ValueError("Parametr 'data' musí být slovník (dict).")
|
||
return self._request("POST", f"/{self._res(resource)}/{record_id}/", json=data)
|
||
|
||
def delete_record(self, resource: str, record_id: int | str) -> dict:
|
||
"""Smaže záznam."""
|
||
return self._request("DELETE", f"/{self._res(resource)}/{record_id}/")
|
||
|
||
def call(
|
||
self,
|
||
method: str,
|
||
path: str,
|
||
params: Optional[dict] = None,
|
||
json: Optional[Any] = None,
|
||
) -> dict:
|
||
"""Univerzální volání pro vnořené zdroje a speciální akce.
|
||
|
||
Příklady cest: ``/company/{id}/address/``,
|
||
``/company/{id}/lock``, ``/company/{id}/merge/{sourceId}/``.
|
||
"""
|
||
return self._request(method.upper(), path, params=params, json=json)
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Pojmenované zkratky pro nejčastější entity (tenké wrappery)
|
||
# ------------------------------------------------------------------ #
|
||
def create_company(self, data: dict) -> dict:
|
||
"""Vytvoří firmu (company) v RAYNET CRM.
|
||
|
||
Povinná pole: ``name``, ``rating``, ``state``, ``role``.
|
||
Příklad struktury viz :data:`EXAMPLE_COMPANY` níže.
|
||
"""
|
||
missing = [f for f in ("name", "rating", "state", "role") if not (data or {}).get(f)]
|
||
if missing:
|
||
raise ValueError(
|
||
"Firma musí mít vyplněná povinná pole: " + ", ".join(missing) + "."
|
||
)
|
||
return self.create_record("company", data)
|
||
|
||
def list_companies(self, **params: Any) -> dict:
|
||
return self.list_records("company", params or None)
|
||
|
||
def get_company(self, company_id: int | str) -> dict:
|
||
return self.get_record("company", company_id)
|
||
|
||
def update_company(self, company_id: int | str, data: dict) -> dict:
|
||
return self.update_record("company", company_id, data)
|
||
|
||
def delete_company(self, company_id: int | str) -> dict:
|
||
return self.delete_record("company", company_id)
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Interní HTTP vrstva
|
||
# ------------------------------------------------------------------ #
|
||
@staticmethod
|
||
def _res(resource: str) -> str:
|
||
"""Očistí název resource (bez lomítek), ať nejde sestavit divná URL."""
|
||
cleaned = (resource or "").strip().strip("/")
|
||
if not cleaned:
|
||
raise ValueError("Název resource nesmí být prázdný.")
|
||
return cleaned
|
||
|
||
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"],
|
||
}
|