diff --git a/projekt-matching/projektmatch/espocrm.py b/projekt-matching/projektmatch/espocrm.py new file mode 100644 index 0000000..f520ff2 --- /dev/null +++ b/projekt-matching/projektmatch/espocrm.py @@ -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]) diff --git a/projekt-matching/tests/test_espocrm.py b/projekt-matching/tests/test_espocrm.py new file mode 100644 index 0000000..8fe4afe --- /dev/null +++ b/projekt-matching/tests/test_espocrm.py @@ -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")