services: add clan consent/visibility and oneok adapter stack
This commit is contained in:
13
services/oneok-crm-adapter/Dockerfile
Normal file
13
services/oneok-crm-adapter/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app.py .
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
EXPOSE 8088
|
||||
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8088"]
|
||||
372
services/oneok-crm-adapter/app.py
Normal file
372
services/oneok-crm-adapter/app.py
Normal file
@@ -0,0 +1,372 @@
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException, Query
|
||||
|
||||
DB_PATH = Path(os.getenv("ONEOK_CRM_DB_PATH", "/data/oneok_crm.sqlite"))
|
||||
API_KEY = os.getenv("ONEOK_ADAPTER_API_KEY", "").strip()
|
||||
|
||||
app = FastAPI(title="1OK CRM Adapter", version="1.0.0")
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _connect() -> sqlite3.Connection:
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def _init_db() -> None:
|
||||
conn = _connect()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
id TEXT PRIMARY KEY,
|
||||
full_name TEXT,
|
||||
phone TEXT,
|
||||
email TEXT,
|
||||
preferred_contact TEXT,
|
||||
notes TEXT,
|
||||
payload_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS sites (
|
||||
id TEXT PRIMARY KEY,
|
||||
client_id TEXT,
|
||||
address_text TEXT,
|
||||
payload_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS window_units (
|
||||
id TEXT PRIMARY KEY,
|
||||
site_id TEXT,
|
||||
label TEXT,
|
||||
payload_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS quotes (
|
||||
id TEXT PRIMARY KEY,
|
||||
client_id TEXT,
|
||||
site_id TEXT,
|
||||
payload_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
site_id TEXT,
|
||||
job_type TEXT,
|
||||
status TEXT,
|
||||
payload_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _auth(authorization: Optional[str] = Header(default=None)) -> None:
|
||||
if not API_KEY:
|
||||
return
|
||||
expected = f"Bearer {API_KEY}"
|
||||
if authorization != expected:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
|
||||
def _upsert_generic(
|
||||
table: str,
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
id_field: str = "id",
|
||||
indexed_fields: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
entity_id = str(payload.get(id_field) or uuid.uuid4())
|
||||
now = _utc_now()
|
||||
merged = dict(payload)
|
||||
merged[id_field] = entity_id
|
||||
idx = indexed_fields or {}
|
||||
|
||||
conn = _connect()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(f"SELECT id FROM {table} WHERE id = ?", (entity_id,))
|
||||
exists = cur.fetchone() is not None
|
||||
|
||||
if table == "clients":
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO clients(id, full_name, phone, email, preferred_contact, notes, payload_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
full_name=excluded.full_name,
|
||||
phone=excluded.phone,
|
||||
email=excluded.email,
|
||||
preferred_contact=excluded.preferred_contact,
|
||||
notes=excluded.notes,
|
||||
payload_json=excluded.payload_json,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(
|
||||
entity_id,
|
||||
str(idx.get("full_name") or ""),
|
||||
str(idx.get("phone") or ""),
|
||||
str(idx.get("email") or ""),
|
||||
str(idx.get("preferred_contact") or ""),
|
||||
str(idx.get("notes") or ""),
|
||||
json.dumps(merged, ensure_ascii=False),
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
elif table == "sites":
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO sites(id, client_id, address_text, payload_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
client_id=excluded.client_id,
|
||||
address_text=excluded.address_text,
|
||||
payload_json=excluded.payload_json,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(
|
||||
entity_id,
|
||||
str(idx.get("client_id") or ""),
|
||||
str(idx.get("address_text") or ""),
|
||||
json.dumps(merged, ensure_ascii=False),
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
elif table == "window_units":
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO window_units(id, site_id, label, payload_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
site_id=excluded.site_id,
|
||||
label=excluded.label,
|
||||
payload_json=excluded.payload_json,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(
|
||||
entity_id,
|
||||
str(idx.get("site_id") or ""),
|
||||
str(idx.get("label") or ""),
|
||||
json.dumps(merged, ensure_ascii=False),
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
elif table == "quotes":
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO quotes(id, client_id, site_id, payload_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
client_id=excluded.client_id,
|
||||
site_id=excluded.site_id,
|
||||
payload_json=excluded.payload_json,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(
|
||||
entity_id,
|
||||
str(idx.get("client_id") or ""),
|
||||
str(idx.get("site_id") or ""),
|
||||
json.dumps(merged, ensure_ascii=False),
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
elif table == "jobs":
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO jobs(id, site_id, job_type, status, payload_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
site_id=excluded.site_id,
|
||||
job_type=excluded.job_type,
|
||||
status=excluded.status,
|
||||
payload_json=excluded.payload_json,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(
|
||||
entity_id,
|
||||
str(idx.get("site_id") or ""),
|
||||
str(idx.get("job_type") or ""),
|
||||
str(idx.get("status") or "new"),
|
||||
json.dumps(merged, ensure_ascii=False),
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return {"id": entity_id, "created": not exists}
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def _startup() -> None:
|
||||
_init_db()
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> Dict[str, Any]:
|
||||
return {"status": "ok", "service": "oneok-crm-adapter"}
|
||||
|
||||
|
||||
@app.get("/crm/search_client")
|
||||
def search_client(query: str = Query(...), _: None = Depends(_auth)) -> Dict[str, Any]:
|
||||
q = f"%{query.strip().lower()}%"
|
||||
conn = _connect()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, full_name, phone, email, payload_json, updated_at
|
||||
FROM clients
|
||||
WHERE lower(full_name) LIKE ? OR lower(phone) LIKE ? OR lower(email) LIKE ?
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 20
|
||||
""",
|
||||
(q, q, q),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
items = []
|
||||
for r in rows:
|
||||
payload = json.loads(r["payload_json"])
|
||||
payload["id"] = r["id"]
|
||||
items.append(payload)
|
||||
return {"count": len(items), "items": items}
|
||||
|
||||
|
||||
@app.post("/crm/upsert_client")
|
||||
def upsert_client(client_payload: Dict[str, Any], _: None = Depends(_auth)) -> Dict[str, Any]:
|
||||
result = _upsert_generic(
|
||||
"clients",
|
||||
client_payload,
|
||||
indexed_fields={
|
||||
"full_name": client_payload.get("full_name") or client_payload.get("label"),
|
||||
"phone": client_payload.get("phone"),
|
||||
"email": client_payload.get("email"),
|
||||
"preferred_contact": client_payload.get("preferred_contact"),
|
||||
"notes": client_payload.get("notes"),
|
||||
},
|
||||
)
|
||||
return {"client_id": result["id"], "created": result["created"]}
|
||||
|
||||
|
||||
@app.post("/crm/upsert_site")
|
||||
def upsert_site(site_payload: Dict[str, Any], _: None = Depends(_auth)) -> Dict[str, Any]:
|
||||
result = _upsert_generic(
|
||||
"sites",
|
||||
site_payload,
|
||||
indexed_fields={
|
||||
"client_id": site_payload.get("client_id"),
|
||||
"address_text": site_payload.get("address_text"),
|
||||
},
|
||||
)
|
||||
return {"site_id": result["id"], "created": result["created"]}
|
||||
|
||||
|
||||
@app.post("/crm/upsert_window_unit")
|
||||
def upsert_window_unit(window_payload: Dict[str, Any], _: None = Depends(_auth)) -> Dict[str, Any]:
|
||||
result = _upsert_generic(
|
||||
"window_units",
|
||||
window_payload,
|
||||
indexed_fields={
|
||||
"site_id": window_payload.get("site_id"),
|
||||
"label": window_payload.get("label") or window_payload.get("room"),
|
||||
},
|
||||
)
|
||||
return {"window_id": result["id"], "created": result["created"]}
|
||||
|
||||
|
||||
@app.post("/crm/create_quote")
|
||||
def create_quote(quote_payload: Dict[str, Any], _: None = Depends(_auth)) -> Dict[str, Any]:
|
||||
result = _upsert_generic(
|
||||
"quotes",
|
||||
quote_payload,
|
||||
indexed_fields={
|
||||
"client_id": quote_payload.get("client_id"),
|
||||
"site_id": quote_payload.get("site_id"),
|
||||
},
|
||||
)
|
||||
return {"quote_id": result["id"], "created": result["created"]}
|
||||
|
||||
|
||||
@app.patch("/crm/update_quote")
|
||||
def update_quote(body: Dict[str, Any], _: None = Depends(_auth)) -> Dict[str, Any]:
|
||||
quote_id = body.get("quote_id")
|
||||
patch = body.get("patch")
|
||||
if not quote_id or not isinstance(patch, dict):
|
||||
raise HTTPException(status_code=400, detail="quote_id and patch required")
|
||||
|
||||
conn = _connect()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT payload_json FROM quotes WHERE id = ?", (quote_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="quote not found")
|
||||
payload = json.loads(row["payload_json"])
|
||||
payload.update(patch)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
result = _upsert_generic(
|
||||
"quotes",
|
||||
payload,
|
||||
indexed_fields={"client_id": payload.get("client_id"), "site_id": payload.get("site_id")},
|
||||
)
|
||||
return {"quote_id": result["id"], "updated": True}
|
||||
|
||||
|
||||
@app.post("/crm/create_job")
|
||||
def create_job(job_payload: Dict[str, Any], _: None = Depends(_auth)) -> Dict[str, Any]:
|
||||
result = _upsert_generic(
|
||||
"jobs",
|
||||
job_payload,
|
||||
indexed_fields={
|
||||
"site_id": job_payload.get("site_id"),
|
||||
"job_type": job_payload.get("job_type"),
|
||||
"status": job_payload.get("status") or "new",
|
||||
},
|
||||
)
|
||||
return {"job_id": result["id"], "created": result["created"]}
|
||||
2
services/oneok-crm-adapter/requirements.txt
Normal file
2
services/oneok-crm-adapter/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.32.1
|
||||
Reference in New Issue
Block a user