feat(projekt-matching): EspoCRM client with dedup helpers and retries
This commit is contained in:
108
projekt-matching/projektmatch/espocrm.py
Normal file
108
projekt-matching/projektmatch/espocrm.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"""Minimal EspoCRM REST client (X-Api-Key auth, in-run retries)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
class EspoError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class EspoClient:
|
||||||
|
def __init__(self, base: str, api_key: str, retries: int = 2,
|
||||||
|
backoff: float = 2.0):
|
||||||
|
self.base = base.rstrip("/")
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers["X-Api-Key"] = api_key
|
||||||
|
self.retries = retries
|
||||||
|
self.backoff = backoff
|
||||||
|
|
||||||
|
def _req(self, method: str, path: str, **kw):
|
||||||
|
last = None
|
||||||
|
for attempt in range(self.retries + 1):
|
||||||
|
try:
|
||||||
|
r = self.session.request(method, f"{self.base}/{path}",
|
||||||
|
timeout=30, **kw)
|
||||||
|
if r.status_code < 500:
|
||||||
|
if r.status_code >= 400:
|
||||||
|
raise EspoError(
|
||||||
|
f"{method} {path} -> {r.status_code} "
|
||||||
|
f"{r.headers.get('X-Status-Reason', '')}")
|
||||||
|
return r.json() if r.text else {}
|
||||||
|
last = EspoError(f"{method} {path} -> {r.status_code}")
|
||||||
|
except (requests.ConnectionError, requests.Timeout) as exc:
|
||||||
|
last = exc
|
||||||
|
time.sleep(self.backoff * (attempt + 1))
|
||||||
|
raise EspoError(str(last))
|
||||||
|
|
||||||
|
def search(self, entity, wtype, attr, value, select="name", max_size=50):
|
||||||
|
params = {"where[0][type]": wtype, "where[0][attribute]": attr,
|
||||||
|
"where[0][value]": value, "select": select,
|
||||||
|
"maxSize": max_size}
|
||||||
|
return self._req("GET", entity, params=params).get("list", [])
|
||||||
|
|
||||||
|
def find_opportunity_by_link(self, url):
|
||||||
|
hits = self.search("Opportunity", "equals", "cProjektlink", url)
|
||||||
|
return hits[0] if hits else None
|
||||||
|
|
||||||
|
def team_id(self, name):
|
||||||
|
hits = self.search("Team", "equals", "name", name)
|
||||||
|
if not hits:
|
||||||
|
raise EspoError(
|
||||||
|
f"Team '{name}' nicht gefunden — cowork-api braucht Lese-"
|
||||||
|
f"Zugriff auf Team, und das Team muss existieren")
|
||||||
|
return hits[0]["id"]
|
||||||
|
|
||||||
|
def ensure_account(self, name, acc_type):
|
||||||
|
for hit in self.search("Account", "contains", "name",
|
||||||
|
core_token(name), select="name,type"):
|
||||||
|
if hit["name"].strip().lower() == name.strip().lower():
|
||||||
|
return hit["id"]
|
||||||
|
return self._req("POST", "Account",
|
||||||
|
json={"name": name, "type": acc_type})["id"]
|
||||||
|
|
||||||
|
def ensure_contact(self, first, last, account_id):
|
||||||
|
full = f"{first} {last}".strip().lower()
|
||||||
|
for hit in self.search("Contact", "contains", "name", last,
|
||||||
|
select="name,accountName"):
|
||||||
|
if hit["name"].strip().lower() == full:
|
||||||
|
return hit["id"]
|
||||||
|
return self._req("POST", "Contact",
|
||||||
|
json={"firstName": first, "lastName": last,
|
||||||
|
"accountId": account_id})["id"]
|
||||||
|
|
||||||
|
def unique_opportunity_name(self, name):
|
||||||
|
existing = {h["name"] for h in self.search(
|
||||||
|
"Opportunity", "startsWith", "name", name, max_size=100)}
|
||||||
|
if name not in existing:
|
||||||
|
return name
|
||||||
|
n = 2
|
||||||
|
while f"{name} ({n})" in existing:
|
||||||
|
n += 1
|
||||||
|
return f"{name} ({n})"
|
||||||
|
|
||||||
|
def create_opportunity(self, payload):
|
||||||
|
return self._req("POST", "Opportunity", json=payload)
|
||||||
|
|
||||||
|
def get_opportunity(self, oid):
|
||||||
|
return self._req("GET", f"Opportunity/{oid}")
|
||||||
|
|
||||||
|
def delete(self, entity, oid):
|
||||||
|
return self._req("DELETE", f"{entity}/{oid}")
|
||||||
|
|
||||||
|
|
||||||
|
def core_token(name: str) -> str:
|
||||||
|
for tok in re.split(r"[^\wÄÖÜäöüß]+", name or ""):
|
||||||
|
if len(tok) > 2:
|
||||||
|
return tok
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def split_person(full: str):
|
||||||
|
parts = (full or "").split()
|
||||||
|
if not parts:
|
||||||
|
return ("", "")
|
||||||
|
return (" ".join(parts[:-1]), parts[-1])
|
||||||
70
projekt-matching/tests/test_espocrm.py
Normal file
70
projekt-matching/tests/test_espocrm.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from projektmatch import espocrm
|
||||||
|
|
||||||
|
|
||||||
|
def make_client(responses):
|
||||||
|
"""Client whose session returns queued mock responses."""
|
||||||
|
client = espocrm.EspoClient("https://crm.example/api/v1", "k", retries=1,
|
||||||
|
backoff=0)
|
||||||
|
client.session = mock.Mock()
|
||||||
|
client.session.request.side_effect = responses
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def resp(status=200, body=None, reason=""):
|
||||||
|
r = mock.Mock()
|
||||||
|
r.status_code = status
|
||||||
|
r.json.return_value = body if body is not None else {}
|
||||||
|
r.text = "x" if body is not None else ""
|
||||||
|
r.headers = {"X-Status-Reason": reason}
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_opportunity_by_link():
|
||||||
|
client = make_client([resp(body={"list": [{"id": "1", "name": "P"}]})])
|
||||||
|
assert client.find_opportunity_by_link("https://x")["id"] == "1"
|
||||||
|
params = client.session.request.call_args.kwargs["params"]
|
||||||
|
assert params["where[0][type]"] == "equals"
|
||||||
|
assert params["where[0][attribute]"] == "cProjektlink"
|
||||||
|
|
||||||
|
|
||||||
|
def test_team_id_missing_raises():
|
||||||
|
client = make_client([resp(body={"list": []})])
|
||||||
|
with pytest.raises(espocrm.EspoError, match="DesTEngS"):
|
||||||
|
client.team_id("DesTEngS")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_account_exact_match_and_create():
|
||||||
|
hits = {"list": [{"id": "a1", "name": "Aristo Group", "type": "Reseller"}]}
|
||||||
|
client = make_client([resp(body=hits)])
|
||||||
|
assert client.ensure_account("Aristo Group", "Reseller") == "a1"
|
||||||
|
client = make_client([resp(body={"list": []}), resp(body={"id": "a2"})])
|
||||||
|
assert client.ensure_account("Neue GmbH", "Customer") == "a2"
|
||||||
|
payload = client.session.request.call_args.kwargs["json"]
|
||||||
|
assert payload == {"name": "Neue GmbH", "type": "Customer"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unique_opportunity_name_suffix():
|
||||||
|
hits = {"list": [{"name": "Projekt X"}, {"name": "Projekt X (2)"}]}
|
||||||
|
client = make_client([resp(body=hits)])
|
||||||
|
assert client.unique_opportunity_name("Projekt X") == "Projekt X (3)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_retry_on_500_then_success():
|
||||||
|
client = make_client([resp(status=500), resp(body={"list": []})])
|
||||||
|
assert client.search("Team", "equals", "name", "X") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_400_raises_with_reason():
|
||||||
|
client = make_client([resp(status=400, body={}, reason="bad field")])
|
||||||
|
with pytest.raises(espocrm.EspoError, match="bad field"):
|
||||||
|
client.search("Team", "equals", "name", "X")
|
||||||
|
|
||||||
|
|
||||||
|
def test_helpers():
|
||||||
|
assert espocrm.core_token("Aristo Group GmbH") == "Aristo"
|
||||||
|
assert espocrm.split_person("Max Muster Mann") == ("Max Muster", "Mann")
|
||||||
|
assert espocrm.split_person("Mann") == ("", "Mann")
|
||||||
Reference in New Issue
Block a user