Orquestra create_client/order FOSS, bridge OpenPanel com metadata, upsert res.partner Odoo, mail-bundle wizard e webhook order-activated. Co-authored-by: Cursor <cursoragent@cursor.com>
233 lines
7.3 KiB
Python
233 lines
7.3 KiB
Python
"""Cliente FOSSBilling Admin API."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import secrets
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from app.vm123.role_map import FOSS_GROUP_BY_ROLE
|
|
|
|
FOSS_BASE = os.getenv("FOSSBILLING_URL", "https://financeiro.ligbox.com.br").rstrip("/")
|
|
FOSS_ADMIN_USER = os.getenv("FOSS_ADMIN_USER", "admin")
|
|
FOSS_ADMIN_API_KEY = os.getenv("FOSS_ADMIN_API_KEY", os.getenv("FOSS_API_KEY", ""))
|
|
FOSS_PUBLIC_ADMIN = os.getenv("FOSS_PUBLIC_ADMIN_URL", f"{FOSS_BASE}/admin")
|
|
|
|
|
|
class FossConfigError(RuntimeError):
|
|
pass
|
|
|
|
|
|
def _configured() -> bool:
|
|
return bool(FOSS_ADMIN_API_KEY)
|
|
|
|
|
|
def _auth():
|
|
if not _configured():
|
|
raise FossConfigError("FOSS_ADMIN_API_KEY não configurado no Desk")
|
|
return (FOSS_ADMIN_USER, FOSS_ADMIN_API_KEY)
|
|
|
|
|
|
def _post(path: str, payload: dict) -> dict[str, Any]:
|
|
url = f"{FOSS_BASE}/api/admin/{path.lstrip('/')}"
|
|
with httpx.Client(timeout=20.0) as client:
|
|
res = client.post(url, json=payload, auth=_auth())
|
|
if res.status_code >= 400:
|
|
raise RuntimeError(f"FOSS {path} HTTP {res.status_code}: {res.text[:300]}")
|
|
try:
|
|
return res.json()
|
|
except Exception:
|
|
return {"raw": res.text}
|
|
|
|
|
|
def _extract_id(data: dict[str, Any]) -> int | str | None:
|
|
if data.get("id") is not None:
|
|
return data["id"]
|
|
result = data.get("result")
|
|
if isinstance(result, dict) and result.get("id") is not None:
|
|
return result["id"]
|
|
if result is not None and not isinstance(result, dict):
|
|
return result
|
|
return None
|
|
|
|
|
|
def _list_items(data: dict[str, Any]) -> list[dict[str, Any]]:
|
|
result = data.get("result")
|
|
if isinstance(result, dict) and isinstance(result.get("list"), list):
|
|
return result["list"]
|
|
if isinstance(data.get("list"), list):
|
|
return data["list"]
|
|
return []
|
|
|
|
|
|
def find_client_by_email(email: str) -> dict[str, Any] | None:
|
|
data = _post("client/get_list", {"per_page": 50, "search": email.strip()})
|
|
items = _list_items(data)
|
|
if not items:
|
|
return None
|
|
needle = email.strip().lower()
|
|
for item in items:
|
|
if str(item.get("email", "")).lower() == needle:
|
|
return item
|
|
return items[0] if items else None
|
|
|
|
|
|
def find_client_by_domain(domain: str) -> dict[str, Any] | None:
|
|
dom = domain.strip().lower()
|
|
data = _post("client/get_list", {"per_page": 100})
|
|
items = _list_items(data)
|
|
for item in items:
|
|
for field in ("company", "company_vat", "email"):
|
|
val = str(item.get(field, "")).lower()
|
|
if dom in val:
|
|
return item
|
|
return None
|
|
|
|
|
|
def find_product_by_slug(slug: str) -> dict[str, Any] | None:
|
|
needle = slug.strip().lower()
|
|
data = _post("product/get_list", {"per_page": 100})
|
|
for item in _list_items(data):
|
|
if str(item.get("slug", "")).lower() == needle:
|
|
return item
|
|
title_slug = str(item.get("title", "")).lower().replace(" ", "-")
|
|
if title_slug == needle:
|
|
return item
|
|
return None
|
|
|
|
|
|
def create_client(*, mapped: dict[str, Any], password: str | None = None) -> dict[str, Any]:
|
|
"""Cria cliente FOSS — idempotente por email (Spec 043 §4.1)."""
|
|
email = str(mapped.get("email") or "").strip().lower()
|
|
if not email:
|
|
raise ValueError("email obrigatório para FOSS client")
|
|
existing = find_client_by_email(email)
|
|
if existing:
|
|
cid = existing.get("id")
|
|
return {
|
|
"client_id": cid,
|
|
"email": email,
|
|
"created": False,
|
|
"reused": True,
|
|
}
|
|
pwd = password or secrets.token_urlsafe(14)
|
|
payload: dict[str, Any] = {
|
|
"email": email,
|
|
"first_name": mapped.get("first_name") or "",
|
|
"last_name": mapped.get("last_name") or "",
|
|
"company": mapped.get("company") or "",
|
|
"company_vat": mapped.get("company_vat") or "",
|
|
"phone": mapped.get("phone") or "",
|
|
"address_1": mapped.get("address_1") or "",
|
|
"city": mapped.get("city") or "",
|
|
"state": mapped.get("state") or "",
|
|
"postcode": mapped.get("postcode") or "",
|
|
"country": mapped.get("country") or "BR",
|
|
"currency": mapped.get("currency") or "BRL",
|
|
"status": mapped.get("status") or "active",
|
|
"password": pwd,
|
|
"password_confirm": pwd,
|
|
}
|
|
result = _post("client/create", payload)
|
|
cid = _extract_id(result)
|
|
return {
|
|
"client_id": cid,
|
|
"email": email,
|
|
"created": True,
|
|
"reused": False,
|
|
"admin_url": FOSS_PUBLIC_ADMIN,
|
|
}
|
|
|
|
|
|
def _split_domain(domain: str) -> tuple[str, str]:
|
|
parts = domain.strip().lower().split(".")
|
|
if len(parts) < 2:
|
|
return (parts[0], "com.br")
|
|
return (parts[0], ".".join(parts[1:]))
|
|
|
|
|
|
def create_order(
|
|
*,
|
|
client_id: int | str,
|
|
product_slug: str,
|
|
domain: str,
|
|
period: str = "1M",
|
|
activate: bool = True,
|
|
config: dict[str, Any] | None = None,
|
|
manager_email: str | None = None,
|
|
manager_name: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Cria e activa pedido hosting FOSS (Spec 043 §4.2)."""
|
|
product = find_product_by_slug(product_slug)
|
|
if not product:
|
|
raise RuntimeError(f"produto FOSS não encontrado: {product_slug}")
|
|
product_id = product.get("id")
|
|
sld, tld = _split_domain(domain)
|
|
payload: dict[str, Any] = {
|
|
"client_id": client_id,
|
|
"product_id": product_id,
|
|
"period": period,
|
|
"currency": "BRL",
|
|
"activate": 1 if activate else 0,
|
|
"config": {
|
|
"domain": {
|
|
"action": "owndomain",
|
|
"owndomain_sld": sld,
|
|
"owndomain_tld": tld,
|
|
},
|
|
},
|
|
}
|
|
if config:
|
|
for key, val in config.items():
|
|
if val is not None:
|
|
payload["config"][key] = val
|
|
if manager_email:
|
|
payload["config"]["manager_email"] = manager_email
|
|
if manager_name:
|
|
payload["config"]["manager_name"] = manager_name
|
|
payload["config"]["domain_name"] = domain
|
|
result = _post("order/create", payload)
|
|
order_id = _extract_id(result)
|
|
return {
|
|
"order_id": order_id,
|
|
"client_id": client_id,
|
|
"product_id": product_id,
|
|
"product_slug": product_slug,
|
|
"domain": domain,
|
|
"activated": activate,
|
|
"created": True,
|
|
}
|
|
|
|
|
|
def staff_group_name_for_role(desk_role: str) -> str | None:
|
|
return FOSS_GROUP_BY_ROLE.get(desk_role)
|
|
|
|
|
|
def create_staff(*, email: str, name: str, desk_role: str, password: str | None = None) -> dict[str, Any]:
|
|
"""Cria staff FOSS — grupo staff deve existir no Admin (manual v1)."""
|
|
group_name = staff_group_name_for_role(desk_role)
|
|
if not group_name:
|
|
return {"skipped": True, "reason": f"role {desk_role} sem grupo FOSS"}
|
|
pwd = password or secrets.token_urlsafe(14)
|
|
payload: dict[str, Any] = {
|
|
"email": email.strip().lower(),
|
|
"name": name,
|
|
"password": pwd,
|
|
"status": "active",
|
|
"admin_group_id": group_name,
|
|
}
|
|
try:
|
|
result = _post("staff/create", payload)
|
|
except RuntimeError as exc:
|
|
if "admin_group" in str(exc).lower() or "group" in str(exc).lower():
|
|
return {"skipped": True, "reason": str(exc), "group": group_name}
|
|
raise
|
|
return {
|
|
"foss_staff_id": result.get("id") or result.get("result"),
|
|
"email": email,
|
|
"group": group_name,
|
|
"admin_url": FOSS_PUBLIC_ADMIN,
|
|
"created": True,
|
|
}
|