This commit is contained in:
JiriUhlir
2026-06-18 15:59:55 +02:00
parent a5795fe344
commit e3dccc46d3
3 changed files with 656 additions and 113 deletions
+224 -80
View File
@@ -1,10 +1,33 @@
import logging
import os
from typing import Any
from typing import Any, Optional, Type
from fastapi import Body, Depends, FastAPI, Header, HTTPException, Query, Request
from pydantic import BaseModel, ConfigDict, Field, create_model
from app.models import CompanyData, CreateCompanyResponse
from app.models import (
BusinessCaseData,
CompanyData,
CreateResponse,
DetailResponse,
EmailData,
EventData,
InvoiceData,
LeadData,
LetterData,
ListResponse,
MeetingData,
OfferData,
PersonData,
PhoneCallData,
PriceListData,
ProductData,
ProjectData,
SalesOrderData,
SimpleResponse,
TaskData,
WebhookData,
)
from app.raynet_client import (
RaynetAuthError,
RaynetClient,
@@ -19,8 +42,7 @@ APP_NAME = os.getenv("APP_NAME", "raynet")
APP_VERSION = os.getenv("APP_VERSION", "1.0.0")
ROOT_PATH = os.getenv("ROOT_PATH", "")
# Popis se zobrazí nahoře ve Swaggeru (/docs) přihlašovací údaje jsou
# společné pro celé připojení a posílají se v hlavičkách u KAŽDÉHO requestu.
# Zobrazí se nahoře ve Swaggeru (/docs).
API_DESCRIPTION = """
Stateless proxy nad **RAYNET CRM API v2**.
@@ -28,20 +50,25 @@ Stateless proxy nad **RAYNET CRM API v2**.
Každý endpoint vyžaduje tři hlavičky. Jsou stejné pro všechna volání:
| Hlavička | Co to je | Kde to vzít |
|-------------------|--------------------|-------------|
| **`X-Api-Key`** | API klíč (secret) | V RAYNET CRM: **Nastavení → Klíč k API** (vygeneruj / zkopíruj). |
| **`X-Raynet-Email`** | Email uživatele | Přihlašovací email do RAYNET (tvoří dvojici s API klíčem pro Basic Auth). |
| **`X-Instance-Name`** | Název instance | Identifikátor tvé RAYNET instance (subdoména účtu). |
| Hlavička | Co to je | Kde to vzít |
|-----------------------|--------------------|-------------|
| **`X-Api-Key`** | API klíč (secret) | V RAYNET CRM: **Nastavení → Klíč k API**. |
| **`X-Raynet-Email`** | Email uživatele | Přihlašovací email (tvoří s API klíčem Basic Auth). |
| **`X-Instance-Name`** | Název instance | Identifikátor RAYNET instance (subdoména účtu). |
`X-Api-Key` je **secret** nikdy se neloguje ani nevrací z endpointů.
## Endpointy
- Typované zkratky pro firmy: `POST/GET/PUT/DELETE /company...`
- Generický průchod na celé API: `/api/{resource}` podporuje libovolnou
RAYNET entitu (`company`, `person`, `lead`, `businessCase`, `activity`,
`product`, `offer`, `order`, `project`, ...).
Typované entity (vidíš strukturu objektu i odpovědi ve Swaggeru) pro každou
platí `POST` (vytvořit), `GET` (seznam), `GET /{id}` (detail), `PUT /{id}`
(úprava), `DELETE /{id}`:
`company`, `person`, `lead`, `businessCase`, `task`, `product`, `offer`,
`salesOrder`, `invoice`.
Generický průchod na **zbytek API** (např. `userAccount`, `project`,
`priceList`, `email`, `file`, číselníky): `/api/{resource}`.
Plná dokumentace RAYNET: <https://app.raynetcrm.com/api/doc/index-en.html>
"""
@@ -58,15 +85,9 @@ app = FastAPI(
# Sdílená credentials dependency 3 hlavičky pro celé připojení.
# --------------------------------------------------------------------------- #
def get_client(
x_api_key: str = Header(
..., alias="X-Api-Key", description="RAYNET API klíč (secret)."
),
x_raynet_email: str = Header(
..., alias="X-Raynet-Email", description="Email uživatele RAYNET."
),
x_instance_name: str = Header(
..., alias="X-Instance-Name", description="Název RAYNET instance."
),
x_api_key: str = Header(..., alias="X-Api-Key", description="RAYNET API klíč (secret)."),
x_raynet_email: str = Header(..., alias="X-Raynet-Email", description="Email uživatele RAYNET."),
x_instance_name: str = Header(..., alias="X-Instance-Name", description="Název RAYNET instance."),
):
"""Vytvoří RaynetClient z hlaviček a po dokončení requestu uvolní session."""
client = RaynetClient(
@@ -113,76 +134,138 @@ def version():
# --------------------------------------------------------------------------- #
# Typované zkratky pro firmy (company)
# Generátor typovaných CRUD endpointů pro entitu
# --------------------------------------------------------------------------- #
@app.post("/company", response_model=CreateCompanyResponse, tags=["company"])
def create_company(
company: CompanyData,
client: RaynetClient = Depends(get_client),
):
"""Vytvoří firmu v RAYNET CRM (validovaná data dle CompanyInsertDto)."""
data = company.model_dump(exclude_none=True)
result = _run(client.create_company, data)
return CreateCompanyResponse(id=result.get("id"), success=True, raw=result)
def _make_patch_model(model: Type[BaseModel]) -> Type[BaseModel]:
"""Z Insert modelu vyrobí model pro částečnou úpravu (vše volitelné)."""
fields = {
fname: (Optional[finfo.annotation], None)
for fname, finfo in model.model_fields.items()
}
return create_model(
f"{model.__name__}Patch",
__config__=ConfigDict(extra="allow"),
**fields,
)
@app.get("/company", tags=["company"])
def list_companies(
client: RaynetClient = Depends(get_client),
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=1000),
fulltext: str | None = Query(None, description="Fulltextové hledání"),
):
"""Vrátí seznam firem s podporou stránkování a fulltextu."""
params = {"offset": offset, "limit": limit}
if fulltext:
params["fulltext"] = fulltext
return _run(client.list_companies, **params)
def register_crud(path: str, model: Type[BaseModel], tag: str) -> None:
"""Zaregistruje typované CRUD endpointy pro danou RAYNET entitu."""
patch_model = _make_patch_model(model)
@app.post(
f"/{path}",
response_model=CreateResponse,
tags=[tag],
operation_id=f"{path}_create",
summary=f"Vytvořit ({path})",
)
def _create(payload: model, client: RaynetClient = Depends(get_client)): # type: ignore[valid-type]
data = payload.model_dump(mode="json", exclude_none=True)
res = _run(client.create_record, path, data)
return CreateResponse(success=res.get("success", True), id=res.get("id"), data=res.get("data"))
@app.get(
f"/{path}",
response_model=ListResponse,
tags=[tag],
operation_id=f"{path}_list",
summary=f"Seznam ({path})",
)
def _list(
request: Request,
client: RaynetClient = Depends(get_client),
offset: int = Query(0, ge=0, description="Posun ve výsledcích"),
limit: int = Query(50, ge=1, le=1000, description="Počet záznamů"),
fulltext: Optional[str] = Query(None, description="Fulltextové hledání"),
):
# Předáme všechny query parametry do RAYNET; doplníme výchozí offset/limit.
params = dict(request.query_params)
params.setdefault("offset", offset)
params.setdefault("limit", limit)
res = _run(client.list_records, path, params)
return ListResponse(
success=res.get("success", True),
totalCount=res.get("totalCount"),
data=res.get("data") or [],
)
@app.get(
f"/{path}/{{record_id}}",
response_model=DetailResponse,
tags=[tag],
operation_id=f"{path}_detail",
summary=f"Detail ({path})",
)
def _detail(record_id: int, client: RaynetClient = Depends(get_client)):
res = _run(client.get_record, path, record_id)
return DetailResponse(success=res.get("success", True), data=res.get("data"))
@app.put(
f"/{path}/{{record_id}}",
response_model=SimpleResponse,
tags=[tag],
operation_id=f"{path}_update",
summary=f"Upravit ({path})",
)
def _update(
record_id: int,
payload: patch_model, # type: ignore[valid-type]
client: RaynetClient = Depends(get_client),
):
data = payload.model_dump(mode="json", exclude_none=True)
res = _run(client.update_record, path, record_id, data)
return SimpleResponse(success=res.get("success", True))
@app.delete(
f"/{path}/{{record_id}}",
response_model=SimpleResponse,
tags=[tag],
operation_id=f"{path}_delete",
summary=f"Smazat ({path})",
)
def _delete(record_id: int, client: RaynetClient = Depends(get_client)):
res = _run(client.delete_record, path, record_id)
return SimpleResponse(success=res.get("success", True))
@app.get("/company/{company_id}", tags=["company"])
def get_company(company_id: int, client: RaynetClient = Depends(get_client)):
"""Vrátí detail firmy."""
return _run(client.get_company, company_id)
@app.put("/company/{company_id}", tags=["company"])
def update_company(
company_id: int,
data: dict = Body(..., description="Pole firmy ke změně"),
client: RaynetClient = Depends(get_client),
):
"""Upraví firmu."""
return _run(client.update_company, company_id, data)
@app.delete("/company/{company_id}", tags=["company"])
def delete_company(company_id: int, client: RaynetClient = Depends(get_client)):
"""Smaže firmu."""
return _run(client.delete_company, company_id)
# Registrace typovaných entit (resource path = tag).
ENTITIES: list[tuple[str, Type[BaseModel]]] = [
("company", CompanyData),
("person", PersonData),
("lead", LeadData),
("businessCase", BusinessCaseData),
("offer", OfferData),
("salesOrder", SalesOrderData),
("invoice", InvoiceData),
("product", ProductData),
("priceList", PriceListData),
("project", ProjectData),
("task", TaskData),
("email", EmailData),
("event", EventData),
("meeting", MeetingData),
("phoneCall", PhoneCallData),
("letter", LetterData),
("webhook", WebhookData),
]
for _path, _model in ENTITIES:
register_crud(_path, _model, _path)
# --------------------------------------------------------------------------- #
# Generický průchod na CELÉ RAYNET API
# resource = libovolná entita: company, person, lead, businessCase, activity,
# product, offer, order, project, ...
# Generický průchod na zbytek RAYNET API (netypované entity)
# resource = libovolná entita: userAccount, project, priceList, email, file, ...
# --------------------------------------------------------------------------- #
@app.get("/api/{resource}", tags=["generic"])
def api_list(
resource: str,
request: Request,
client: RaynetClient = Depends(get_client),
):
"""Seznam záznamů entity. Všechny query parametry se předávají do RAYNET
(např. `offset`, `limit`, `fulltext`, `name`, ...)."""
def api_list(resource: str, request: Request, client: RaynetClient = Depends(get_client)):
"""Seznam záznamů entity. Query parametry se předávají do RAYNET."""
params = dict(request.query_params)
return _run(client.list_records, resource, params or None)
@app.get("/api/{resource}/{record_id}", tags=["generic"])
def api_detail(
resource: str, record_id: str, client: RaynetClient = Depends(get_client)
):
def api_detail(resource: str, record_id: str, client: RaynetClient = Depends(get_client)):
"""Detail jednoho záznamu."""
return _run(client.get_record, resource, record_id)
@@ -209,8 +292,69 @@ def api_update(
@app.delete("/api/{resource}/{record_id}", tags=["generic"])
def api_delete(
resource: str, record_id: str, client: RaynetClient = Depends(get_client)
):
def api_delete(resource: str, record_id: str, client: RaynetClient = Depends(get_client)):
"""Smaže záznam."""
return _run(client.delete_record, resource, record_id)
# --------------------------------------------------------------------------- #
# Vnořené zdroje a speciální akce
# Pokrývá např.: /company/{id}/address/, /company/{id}/lock,
# /company/{id}/merge/{srcId}/, /invoice/{id}/cancel, /invoice/{id}/pdfExport,
# /invoice/{id}/payment/, /offer/{id}/item/ atd.
# `sub` je zbytek cesty za /{resource}/{id}/ uveď přesně jak chce RAYNET
# (vč. koncového lomítka u kolekcí, např. "address/" nebo "payment/5/").
# --------------------------------------------------------------------------- #
@app.get("/api/{resource}/{record_id}/{sub:path}", tags=["generic"])
def api_sub_get(
resource: str, record_id: str, sub: str, request: Request,
client: RaynetClient = Depends(get_client),
):
"""GET vnořeného zdroje / akce (např. pdfExport, seznam adres)."""
return _run(client.call, "GET", f"/{resource}/{record_id}/{sub}", dict(request.query_params) or None)
@app.post("/api/{resource}/{record_id}/{sub:path}", tags=["generic"])
def api_sub_post(
resource: str, record_id: str, sub: str,
data: Optional[dict] = Body(None, description="Volitelné tělo (akce nemusí mít žádné)"),
client: RaynetClient = Depends(get_client),
):
"""POST vnořeného zdroje / akce (např. lock, cancel, setPrimary, update)."""
return _run(client.call, "POST", f"/{resource}/{record_id}/{sub}", None, data)
@app.put("/api/{resource}/{record_id}/{sub:path}", tags=["generic"])
def api_sub_put(
resource: str, record_id: str, sub: str,
data: Optional[dict] = Body(None, description="Tělo nového vnořeného záznamu"),
client: RaynetClient = Depends(get_client),
):
"""PUT vnořeného zdroje (create v kolekci, např. address/, payment/, item/)."""
return _run(client.call, "PUT", f"/{resource}/{record_id}/{sub}", None, data)
@app.delete("/api/{resource}/{record_id}/{sub:path}", tags=["generic"])
def api_sub_delete(
resource: str, record_id: str, sub: str,
client: RaynetClient = Depends(get_client),
):
"""DELETE vnořeného zdroje (např. address/5/, payment/3/)."""
return _run(client.call, "DELETE", f"/{resource}/{record_id}/{sub}")
# --------------------------------------------------------------------------- #
# Raw escape-hatch dosáhne na JAKOUKOLI cestu RAYNET API libovolnou metodou.
# Pro speciální případy mimo výše uvedené vzory (např. PUT /invoice/creditNote).
# --------------------------------------------------------------------------- #
class RawRequest(BaseModel):
method: str = Field(..., examples=["GET", "POST", "PUT", "DELETE"])
path: str = Field(..., description="Cesta za base URL, např. /invoice/creditNote", examples=["/company/"])
params: Optional[dict] = Field(default=None, description="Query parametry")
data: Optional[dict] = Field(default=None, description="JSON tělo")
@app.post("/raw", tags=["generic"], summary="Raw volání libovolného RAYNET endpointu")
def raw_call(req: RawRequest, client: RaynetClient = Depends(get_client)):
"""Univerzální volání pokrývá 100 % RAYNET API včetně netypických cest."""
return _run(client.call, req.method, req.path, req.params, req.data)