This commit is contained in:
JiriUhlir
2026-06-18 14:49:29 +02:00
parent 6529893f38
commit 28596fe772
5 changed files with 573 additions and 5 deletions
+53 -3
View File
@@ -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)
+117
View File
@@ -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")
+262
View File
@@ -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"],
}