diff --git a/app/main.py b/app/main.py index 76d9af2..2f0f720 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,18 @@ +import logging import os -from fastapi import FastAPI + +from fastapi import FastAPI, Header, HTTPException + +from app.models import CreateCompanyRequest, CreateCompanyResponse +from app.raynet_client import ( + RaynetAuthError, + RaynetClient, + RaynetError, + RaynetValidationError, +) + +logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO")) +logger = logging.getLogger(__name__) APP_NAME = os.getenv("APP_NAME", "raynet") APP_VERSION = os.getenv("APP_VERSION", "1.0.0") @@ -8,18 +21,55 @@ ROOT_PATH = os.getenv("ROOT_PATH", "") app = FastAPI( title=APP_NAME, version=APP_VERSION, - root_path=ROOT_PATH + root_path=ROOT_PATH, + description="Stateless proxy nad RAYNET CRM API v2.", ) + @app.get("/health") def health(): return {"status": "ok"} + @app.get("/version") def version(): return { "app": APP_NAME, "version": APP_VERSION, "language": "python", - "root_path": ROOT_PATH + "root_path": ROOT_PATH, } + + +@app.post("/company", response_model=CreateCompanyResponse, tags=["company"]) +def create_company( + request: CreateCompanyRequest, + x_api_key: str = Header( + ..., + alias="X-Api-Key", + description="RAYNET API klíč (secret). Předává se výhradně hlavičkou.", + ), +): + """Vytvoří firmu v RAYNET CRM. + + - **X-Api-Key** (hlavička, secret): API klíč z RAYNET (Nastavení > API). + - **email** / **instance_name** (tělo): identifikátory účtu a instance. + - **company** (tělo): data firmy dle RAYNET CompanyInsertDto. + """ + try: + with RaynetClient( + api_key=x_api_key, + email=request.email, + instance_name=request.instance_name, + ) as client: + # Posíláme jen vyplněná pole (žádné None), aby RAYNET nedostal prázdné hodnoty. + data = request.company.model_dump(exclude_none=True) + result = client.create_company(data) + except RaynetAuthError as exc: + raise HTTPException(status_code=401, detail=exc.message) from exc + except RaynetValidationError as exc: + raise HTTPException(status_code=400, detail=exc.message) from exc + except RaynetError as exc: + raise HTTPException(status_code=502, detail=exc.message) from exc + + return CreateCompanyResponse(id=result.get("id"), success=True, raw=result) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..012b3ff --- /dev/null +++ b/app/models.py @@ -0,0 +1,117 @@ +"""Pydantic modely pro RAYNET endpointy. + +Modely odpovídají RAYNET CRM API v2 (CompanyInsertDto). Slouží zároveň jako +zdroj pro Swagger/OpenAPI dokumentaci. +""" + +from enum import Enum +from typing import Optional + +from pydantic import BaseModel, Field + + +class Rating(str, Enum): + A = "A" + B = "B" + C = "C" + + +class CompanyState(str, Enum): + A_POTENTIAL = "A_POTENTIAL" + B_ACTUAL = "B_ACTUAL" + C_DEFERRED = "C_DEFERRED" + D_UNATTRACTIVE = "D_UNATTRACTIVE" + + +class CompanyRole(str, Enum): + A_SUBSCRIBER = "A_SUBSCRIBER" + B_PARTNER = "B_PARTNER" + C_SUPPLIER = "C_SUPPLIER" + D_RIVAL = "D_RIVAL" + + +class TaxPayer(str, Enum): + YES = "YES" + NO = "NO" + + +class Address(BaseModel): + name: Optional[str] = Field(default=None, examples=["Sídlo klienta"]) + street: Optional[str] = Field(default=None, examples=["Francouzská 6167/5"]) + city: Optional[str] = Field(default=None, examples=["Ostrava"]) + province: Optional[str] = Field(default=None, examples=["Morava"]) + zipCode: Optional[str] = Field(default=None, examples=["708 00"]) + country: Optional[str] = Field(default=None, examples=["CZ"]) + lat: Optional[float] = None + lng: Optional[float] = None + + +class ContactInfo(BaseModel): + email: Optional[str] = Field(default=None, examples=["info@raynet.cz"]) + email2: Optional[str] = None + fax: Optional[str] = None + tel1: Optional[str] = Field(default=None, examples=["+420 553 401 520"]) + tel1Type: Optional[str] = Field(default=None, examples=["recepce"]) + tel2: Optional[str] = None + tel2Type: Optional[str] = None + www: Optional[str] = Field(default=None, examples=["www.raynet.cz"]) + doNotSendMM: Optional[bool] = None + otherContact: Optional[str] = None + + +class CompanyAddress(BaseModel): + address: Optional[Address] = None + contactInfo: Optional[ContactInfo] = None + + +class CompanyData(BaseModel): + """Tělo požadavku pro vytvoření firmy (CompanyInsertDto). + + Povinná pole: ``name``, ``rating``, ``state``, ``role``. + """ + + # Povinná pole + name: str = Field(..., examples=["ACME s.r.o."]) + rating: Rating = Field(..., examples=[Rating.A]) + state: CompanyState = Field(..., examples=[CompanyState.A_POTENTIAL]) + role: CompanyRole = Field(..., examples=[CompanyRole.A_SUBSCRIBER]) + + # Volitelná pole + regNumber: Optional[str] = Field(default=None, examples=["12345678"]) + taxNumber: Optional[str] = Field(default=None, examples=["CZ12345678"]) + taxNumber2: Optional[str] = None + taxPayer: Optional[TaxPayer] = None + bankAccount: Optional[str] = None + notice: Optional[str] = None + owner: Optional[int] = Field(default=None, description="ID vlastníka (uživatele)") + category: Optional[int] = None + contactSource: Optional[int] = None + employeesNumber: Optional[int] = None + legalForm: Optional[int] = None + paymentTerm: Optional[int] = None + turnover: Optional[int] = None + territory: Optional[int] = None + addresses: Optional[list[CompanyAddress]] = None + tags: Optional[list[str]] = None + customFields: Optional[dict] = None + + # Umožní i pole, která zde nejsou explicitně vyjmenovaná (RAYNET jich má víc). + model_config = {"extra": "allow"} + + +class CreateCompanyRequest(BaseModel): + """Vstup endpointu /company. + + API klíč (secret) se předává hlavičkou ``X-Api-Key`` – NENÍ součástí tohoto + těla. Email a název instance jsou běžné identifikátory, proto jdou v těle. + """ + + email: str = Field(..., examples=["user@firma.cz"], description="Email uživatele RAYNET") + instance_name: str = Field(..., examples=["moje-instance"], description="Název RAYNET instance") + company: CompanyData + + +class CreateCompanyResponse(BaseModel): + id: Optional[int] = Field(default=None, description="ID nově vytvořené firmy") + success: bool = True + raw: Optional[dict] = Field(default=None, description="Surová odpověď RAYNET API") diff --git a/app/raynet_client.py b/app/raynet_client.py new file mode 100644 index 0000000..e421580 --- /dev/null +++ b/app/raynet_client.py @@ -0,0 +1,262 @@ +"""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"], +} diff --git a/documentation/raynet-company.md b/documentation/raynet-company.md new file mode 100644 index 0000000..95f542f --- /dev/null +++ b/documentation/raynet-company.md @@ -0,0 +1,134 @@ +# RAYNET konektor – vytvoření firmy + +Stateless proxy nad [RAYNET CRM API v2](https://app.raynetcrm.com/api/doc/index-en.html). + +## Autentizace RAYNET + +RAYNET API používá: + +- **HTTP Basic Auth**: username = email uživatele, password = **API klíč** +- hlavičku **`X-Instance-Name`** = název instance +- base URL: `https://app.raynet.cz/api/v2` + +V této službě se **API klíč (secret) předává hlavičkou `X-Api-Key`**. Email a název +instance jsou běžné identifikátory a předávají se v těle požadavku. + +> Secret se nikdy neloguje, necommituje ani nevrací z endpointů (viz AGENTS.md). + +## Endpoint + +```text +POST /company +``` + +Veřejně přes AppFactory reverse proxy: + +```text +POST https://services.csbot.cz/apps//company +``` + +### Hlavičky + +| Hlavička | Povinná | Popis | +|--------------|---------|-------------------------------| +| `X-Api-Key` | ano | RAYNET API klíč (secret) | + +### Tělo požadavku + +```json +{ + "email": "user@firma.cz", + "instance_name": "moje-instance", + "company": { + "name": "ACME s.r.o.", + "rating": "A", + "state": "A_POTENTIAL", + "role": "A_SUBSCRIBER", + "regNumber": "12345678", + "taxNumber": "CZ12345678", + "taxPayer": "YES", + "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"] + } +} +``` + +#### Povinná pole firmy + +| Pole | Typ | Hodnoty | +|----------|--------|---------------------------------------------------------------| +| `name` | string | název firmy | +| `rating` | enum | `A`, `B`, `C` | +| `state` | enum | `A_POTENTIAL`, `B_ACTUAL`, `C_DEFERRED`, `D_UNATTRACTIVE` | +| `role` | enum | `A_SUBSCRIBER`, `B_PARTNER`, `C_SUPPLIER`, `D_RIVAL` | + +Volitelná pole: `regNumber` (IČO), `taxNumber` (DIČ), `taxPayer` (`YES`/`NO`), +`bankAccount`, `notice`, `owner`, `category`, `legalForm`, `paymentTerm`, +`turnover`, `territory`, `addresses`, `tags`, `customFields` a další dle +RAYNET `CompanyInsertDto`. Neuvedená/`null` pole se do RAYNET neposílají. + +### Odpověď (200) + +```json +{ + "id": 123456, + "success": true, + "raw": { "...": "surová odpověď RAYNET" } +} +``` + +### Chybové stavy + +| HTTP | Kdy | +|------|------------------------------------------------------------| +| 400 | RAYNET odmítl data (nevalidní vstup) | +| 401 | chybný API klíč / email / instance (autentizace selhala) | +| 422 | nevalidní tělo požadavku (FastAPI validace) | +| 502 | RAYNET nedostupný / chyba serveru / timeout | + +## Použití konektoru přímo v Pythonu + +```python +from app.raynet_client import RaynetClient, RaynetError + +client = RaynetClient(api_key="...", email="user@firma.cz", instance_name="moje-instance") +try: + company = client.create_company({ + "name": "ACME s.r.o.", + "rating": "A", + "state": "A_POTENTIAL", + "role": "A_SUBSCRIBER", + }) + print(company["id"]) +except RaynetError as exc: + print("Chyba:", exc.message, exc.status_code) +``` + +`RaynetClient` drží `requests.Session` (Basic Auth + `X-Instance-Name`), +automaticky opakuje přechodné chyby (síť, 429, 5xx) a vyhazuje typované +výjimky `RaynetAuthError` / `RaynetValidationError` / `RaynetError`. + +## Ověření po nasazení + +```text +GET https://services.csbot.cz/apps//health +GET https://services.csbot.cz/apps//docs +POST https://services.csbot.cz/apps//company (Swagger „Try it out") +``` diff --git a/requirements.txt b/requirements.txt index 364e2ee..ccf5775 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,7 @@ -fastapi -uvicorn[standard] +# Připnuté verze = deterministický Docker build (viz AGENTS.md). +# Ověřeno: app se importuje a OpenAPI generuje s těmito verzemi. +fastapi==0.136.3 +uvicorn[standard]==0.48.0 +pydantic==2.12.2 +requests==2.32.5 +urllib3==2.5.0