"""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"], }