services: add clan consent/visibility and oneok adapter stack
This commit is contained in:
97
migrations/053_clan_event_store.sql
Normal file
97
migrations/053_clan_event_store.sql
Normal file
@@ -0,0 +1,97 @@
|
||||
-- 053_clan_event_store.sql
|
||||
-- Minimal CLAN event store: consent_events (immutable), artifacts (versioned),
|
||||
-- outbox (pending/done), state_transitions (immutable)
|
||||
|
||||
begin;
|
||||
|
||||
create schema if not exists clan;
|
||||
|
||||
-- 1) Immutable ConsentEvents
|
||||
create table if not exists clan.clan_consent_events (
|
||||
consent_event_id text primary key,
|
||||
payload jsonb not null,
|
||||
decision_type text not null,
|
||||
target_artifact_ids text[] not null,
|
||||
request_id text not null,
|
||||
created_ts timestamptz not null default now(),
|
||||
constraint chk_consent_decision_type check (decision_type in ('approve', 'reject', 'revoke'))
|
||||
);
|
||||
|
||||
create index if not exists idx_clan_consent_events_created_ts
|
||||
on clan.clan_consent_events(created_ts desc);
|
||||
|
||||
-- 2) Versioned Artifacts (CAS by version)
|
||||
create table if not exists clan.clan_artifacts (
|
||||
artifact_id text primary key,
|
||||
artifact_type text not null,
|
||||
status text not null,
|
||||
visibility_level text not null,
|
||||
payload jsonb not null default '{}'::jsonb,
|
||||
provenance jsonb not null default '[]'::jsonb,
|
||||
version bigint not null default 1,
|
||||
created_ts timestamptz not null default now(),
|
||||
updated_ts timestamptz not null default now(),
|
||||
constraint chk_artifact_visibility check (visibility_level in ('public', 'interclan', 'incircle', 'soulsafe', 'sacred')),
|
||||
constraint chk_artifact_status check (
|
||||
status in (
|
||||
'draft',
|
||||
'waiting_for_consent',
|
||||
'needs_confirmation',
|
||||
'approved_for_execution',
|
||||
'confirmed',
|
||||
'rejected',
|
||||
'revoked'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
create index if not exists idx_clan_artifacts_status
|
||||
on clan.clan_artifacts(status);
|
||||
|
||||
create index if not exists idx_clan_artifacts_updated_ts
|
||||
on clan.clan_artifacts(updated_ts desc);
|
||||
|
||||
-- 3) Outbox apply_consent (idempotent by outbox_id)
|
||||
create table if not exists clan.clan_outbox (
|
||||
outbox_id text primary key, -- outbox_{consent_event_id}
|
||||
event_type text not null, -- apply_consent
|
||||
consent_event_id text not null references clan.clan_consent_events(consent_event_id) on delete restrict,
|
||||
target_artifact_ids text[] not null,
|
||||
request_id text not null,
|
||||
status text not null default 'pending', -- pending|done
|
||||
attempts int not null default 0,
|
||||
last_error text,
|
||||
created_ts timestamptz not null default now(),
|
||||
updated_ts timestamptz not null default now(),
|
||||
constraint chk_outbox_status check (status in ('pending', 'done')),
|
||||
constraint chk_outbox_event_type check (event_type in ('apply_consent'))
|
||||
);
|
||||
|
||||
create index if not exists idx_clan_outbox_pending
|
||||
on clan.clan_outbox(status, created_ts);
|
||||
|
||||
-- 4) Immutable Transition Log
|
||||
create table if not exists clan.clan_state_transitions (
|
||||
transition_id text primary key,
|
||||
ts timestamptz not null default now(),
|
||||
artifact_id text not null references clan.clan_artifacts(artifact_id) on delete restrict,
|
||||
artifact_type text not null,
|
||||
from_status text not null,
|
||||
to_status text not null,
|
||||
op text not null,
|
||||
consent_event_id text not null references clan.clan_consent_events(consent_event_id) on delete restrict,
|
||||
decision_type text not null,
|
||||
request_id text not null,
|
||||
visibility_level text not null,
|
||||
versions jsonb not null default '{}'::jsonb,
|
||||
constraint chk_transition_decision_type check (decision_type in ('approve', 'reject', 'revoke')),
|
||||
constraint chk_transition_visibility check (visibility_level in ('public', 'interclan', 'incircle', 'soulsafe', 'sacred'))
|
||||
);
|
||||
|
||||
create index if not exists idx_clan_transitions_artifact
|
||||
on clan.clan_state_transitions(artifact_id, ts desc);
|
||||
|
||||
create index if not exists idx_clan_transitions_consent
|
||||
on clan.clan_state_transitions(consent_event_id, ts desc);
|
||||
|
||||
commit;
|
||||
7
services/clan-consent-adapter/Dockerfile
Normal file
7
services/clan-consent-adapter/Dockerfile
Normal file
@@ -0,0 +1,7 @@
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app.py .
|
||||
COPY clan_consent_outbox_worker.py .
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8111"]
|
||||
225
services/clan-consent-adapter/app.py
Normal file
225
services/clan-consent-adapter/app.py
Normal file
@@ -0,0 +1,225 @@
|
||||
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
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
DB_PATH = Path(os.getenv("CLAN_CONSENT_DB_PATH", "/data/clan_consent.sqlite"))
|
||||
API_KEY = os.getenv("CLAN_ADAPTER_API_KEY", "").strip()
|
||||
VISIBILITY = {"public", "interclan", "incircle", "soulsafe", "sacred"}
|
||||
|
||||
app = FastAPI(title="CLAN Consent 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 consent_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
circle TEXT,
|
||||
subject TEXT,
|
||||
method TEXT,
|
||||
signers_json TEXT,
|
||||
caveats TEXT,
|
||||
visibility_level TEXT,
|
||||
provenance_json TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS testimony_drafts (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
circle TEXT,
|
||||
visibility_level TEXT,
|
||||
content_json TEXT,
|
||||
status TEXT,
|
||||
provenance_json TEXT,
|
||||
consent_event_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class ConsentCreate(BaseModel):
|
||||
circle: str
|
||||
subject: str = Field(description="decision/testimony/bridge/rights")
|
||||
method: str = "live_presence"
|
||||
signers: list[str] = Field(default_factory=list)
|
||||
caveats: Optional[str] = None
|
||||
visibility_level: str = "incircle"
|
||||
provenance: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class TestimonyDraftCreate(BaseModel):
|
||||
title: str
|
||||
circle: str
|
||||
visibility_level: str = "incircle"
|
||||
content: Dict[str, Any] = Field(default_factory=dict)
|
||||
status: str = "draft"
|
||||
provenance: Dict[str, Any] = Field(default_factory=dict)
|
||||
consent_event_id: Optional[str] = None
|
||||
|
||||
|
||||
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 _check_visibility(level: str) -> str:
|
||||
v = (level or "").strip().lower()
|
||||
if v not in VISIBILITY:
|
||||
raise HTTPException(status_code=400, detail=f"invalid visibility_level: {level}")
|
||||
return v
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup() -> None:
|
||||
_init_db()
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> Dict[str, Any]:
|
||||
return {"status": "ok", "service": "clan-consent-adapter"}
|
||||
|
||||
|
||||
@app.post("/consent/events")
|
||||
def create_consent_event(body: ConsentCreate, _: None = Depends(_auth)) -> Dict[str, Any]:
|
||||
cid = str(uuid.uuid4())
|
||||
visibility = _check_visibility(body.visibility_level)
|
||||
now = _utc_now()
|
||||
conn = _connect()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO consent_events(id, circle, subject, method, signers_json, caveats, visibility_level, provenance_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
cid,
|
||||
body.circle,
|
||||
body.subject,
|
||||
body.method,
|
||||
json.dumps(body.signers, ensure_ascii=False),
|
||||
body.caveats or "",
|
||||
visibility,
|
||||
json.dumps(body.provenance, ensure_ascii=False),
|
||||
now,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return {"consent_event_id": cid, "status": "confirmed", "visibility_level": visibility}
|
||||
|
||||
|
||||
@app.get("/consent/events/{event_id}")
|
||||
def get_consent_event(event_id: str, _: None = Depends(_auth)) -> Dict[str, Any]:
|
||||
conn = _connect()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT * FROM consent_events WHERE id = ?", (event_id,))
|
||||
row = cur.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="consent_event_not_found")
|
||||
return {
|
||||
"id": row["id"],
|
||||
"circle": row["circle"],
|
||||
"subject": row["subject"],
|
||||
"method": row["method"],
|
||||
"signers": json.loads(row["signers_json"] or "[]"),
|
||||
"caveats": row["caveats"],
|
||||
"visibility_level": row["visibility_level"],
|
||||
"provenance": json.loads(row["provenance_json"] or "{}"),
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
|
||||
|
||||
@app.post("/testimony/drafts")
|
||||
def create_testimony_draft(body: TestimonyDraftCreate, _: None = Depends(_auth)) -> Dict[str, Any]:
|
||||
tid = str(uuid.uuid4())
|
||||
visibility = _check_visibility(body.visibility_level)
|
||||
status = body.status if body.status in {"draft", "needs_confirmation", "confirmed"} else "draft"
|
||||
now = _utc_now()
|
||||
conn = _connect()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO testimony_drafts(id, title, circle, visibility_level, content_json, status, provenance_json, consent_event_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
tid,
|
||||
body.title,
|
||||
body.circle,
|
||||
visibility,
|
||||
json.dumps(body.content, ensure_ascii=False),
|
||||
status,
|
||||
json.dumps(body.provenance, ensure_ascii=False),
|
||||
body.consent_event_id,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return {"testimony_id": tid, "status": status, "visibility_level": visibility}
|
||||
|
||||
|
||||
@app.get("/testimony/drafts/{testimony_id}")
|
||||
def get_testimony_draft(testimony_id: str, _: None = Depends(_auth)) -> Dict[str, Any]:
|
||||
conn = _connect()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT * FROM testimony_drafts WHERE id = ?", (testimony_id,))
|
||||
row = cur.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="testimony_not_found")
|
||||
return {
|
||||
"id": row["id"],
|
||||
"title": row["title"],
|
||||
"circle": row["circle"],
|
||||
"visibility_level": row["visibility_level"],
|
||||
"content": json.loads(row["content_json"] or "{}"),
|
||||
"status": row["status"],
|
||||
"provenance": json.loads(row["provenance_json"] or "{}"),
|
||||
"consent_event_id": row["consent_event_id"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
398
services/clan-consent-adapter/clan_consent_outbox_worker.py
Normal file
398
services/clan-consent-adapter/clan_consent_outbox_worker.py
Normal file
@@ -0,0 +1,398 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
|
||||
PG_DSN = os.getenv("CLAN_PG_DSN", "postgresql://daarion:DaarionDB2026!@dagi-postgres:5432/daarion_main")
|
||||
BATCH_SIZE = int(os.getenv("CLAN_OUTBOX_BATCH_SIZE", "10"))
|
||||
POLL_INTERVAL = float(os.getenv("CLAN_OUTBOX_POLL_INTERVAL_SEC", "1.0"))
|
||||
MAX_ARTIFACT_CAS_RETRIES = int(os.getenv("CLAN_OUTBOX_MAX_CAS_RETRIES", "5"))
|
||||
APPLIER_ACTOR_ID = os.getenv("CLAN_CONSENT_APPLIER_ACTOR_ID", "system:consent-applier")
|
||||
|
||||
CONSENT_TRANSITION_MAP = {
|
||||
"bridge_request_draft": {
|
||||
"approve": ("approved_for_execution", "export_validated"),
|
||||
"reject": ("rejected", "validated"),
|
||||
"revoke": ("revoked", "corrected"),
|
||||
},
|
||||
"allocation_proposal": {
|
||||
"approve": ("approved_for_execution", "validated"),
|
||||
"reject": ("rejected", "validated"),
|
||||
"revoke": ("revoked", "corrected"),
|
||||
},
|
||||
"access_grant_draft": {
|
||||
"approve": ("approved_for_execution", "policy_checked"),
|
||||
"reject": ("rejected", "validated"),
|
||||
"revoke": ("revoked", "corrected"),
|
||||
},
|
||||
"visibility_change_draft": {
|
||||
"approve": ("approved_for_execution", "policy_checked"),
|
||||
"reject": ("rejected", "validated"),
|
||||
"revoke": ("revoked", "corrected"),
|
||||
},
|
||||
"offline_merge_plan": {
|
||||
"approve": ("approved_for_execution", "merged"),
|
||||
"reject": ("rejected", "validated"),
|
||||
"revoke": ("revoked", "corrected"),
|
||||
},
|
||||
"core_change_draft": {
|
||||
"approve": ("needs_confirmation", "policy_checked"),
|
||||
"reject": ("rejected", "validated"),
|
||||
"revoke": ("revoked", "corrected"),
|
||||
},
|
||||
"testimony_draft": {
|
||||
"approve": ("confirmed", "validated"),
|
||||
"reject": ("rejected", "validated"),
|
||||
"revoke": ("revoked", "corrected"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _id(prefix: str, seed: str) -> str:
|
||||
h = hashlib.sha256(seed.encode("utf-8")).hexdigest()[:20]
|
||||
return f"{prefix}_{h}"
|
||||
|
||||
|
||||
def _now_ts() -> int:
|
||||
return int(time.time())
|
||||
|
||||
|
||||
def _provenance_has_consent(provenance: Any, consent_event_id: str) -> bool:
|
||||
if not isinstance(provenance, list):
|
||||
return False
|
||||
for tr in provenance:
|
||||
ctx = (tr or {}).get("context") or {}
|
||||
op = ((tr or {}).get("operation") or {}).get("op")
|
||||
if ctx.get("consent_event_ref") == consent_event_id and op in {
|
||||
"validated",
|
||||
"export_validated",
|
||||
"policy_checked",
|
||||
"merged",
|
||||
"corrected",
|
||||
}:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _build_trail(
|
||||
consent_event: Dict[str, Any],
|
||||
request_id: str,
|
||||
consent_event_id: str,
|
||||
decision_type: str,
|
||||
op: str,
|
||||
visibility_level: str,
|
||||
versions: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"event_id": _id("prov", f"{consent_event_id}:{op}:{_now_ts()}"),
|
||||
"ts": _now_ts(),
|
||||
"actor": {"type": "system", "id": APPLIER_ACTOR_ID},
|
||||
"source": {"channel": "internal", "request_id": request_id},
|
||||
"context": {
|
||||
"visibility_level": visibility_level,
|
||||
"consent_status": "confirmed" if decision_type == "approve" else "none",
|
||||
"consent_event_ref": consent_event_id,
|
||||
},
|
||||
"operation": {"op": op},
|
||||
"versions": versions,
|
||||
"links": {},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutboxRow:
|
||||
outbox_id: str
|
||||
consent_event_id: str
|
||||
target_artifact_ids: List[str]
|
||||
request_id: str
|
||||
|
||||
|
||||
def _fetch_pending_outbox(cur, limit: int) -> List[OutboxRow]:
|
||||
cur.execute(
|
||||
"""
|
||||
select outbox_id, consent_event_id, target_artifact_ids, request_id
|
||||
from clan.clan_outbox
|
||||
where status='pending'
|
||||
order by created_ts
|
||||
limit %s
|
||||
for update skip locked
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
return [
|
||||
OutboxRow(
|
||||
outbox_id=r["outbox_id"],
|
||||
consent_event_id=r["consent_event_id"],
|
||||
target_artifact_ids=list(r["target_artifact_ids"] or []),
|
||||
request_id=r["request_id"],
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _set_outbox_error(cur, outbox_id: str, error: str) -> None:
|
||||
cur.execute(
|
||||
"""
|
||||
update clan.clan_outbox
|
||||
set attempts = attempts + 1,
|
||||
last_error = %s,
|
||||
updated_ts = now()
|
||||
where outbox_id = %s
|
||||
""",
|
||||
(error[:800], outbox_id),
|
||||
)
|
||||
|
||||
|
||||
def _mark_outbox_done(cur, outbox_id: str) -> None:
|
||||
cur.execute(
|
||||
"""
|
||||
update clan.clan_outbox
|
||||
set status='done', updated_ts=now()
|
||||
where outbox_id=%s and status='pending'
|
||||
""",
|
||||
(outbox_id,),
|
||||
)
|
||||
|
||||
|
||||
def _get_consent_event(cur, consent_event_id: str) -> Optional[Dict[str, Any]]:
|
||||
cur.execute(
|
||||
"select payload from clan.clan_consent_events where consent_event_id=%s",
|
||||
(consent_event_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return row["payload"] if row else None
|
||||
|
||||
|
||||
def _get_artifact(cur, artifact_id: str) -> Optional[Dict[str, Any]]:
|
||||
cur.execute(
|
||||
"""
|
||||
select artifact_id, artifact_type, status, visibility_level, payload, provenance, version
|
||||
from clan.clan_artifacts
|
||||
where artifact_id=%s
|
||||
""",
|
||||
(artifact_id,),
|
||||
)
|
||||
return cur.fetchone()
|
||||
|
||||
|
||||
def _cas_update_artifact(
|
||||
cur,
|
||||
artifact_id: str,
|
||||
expected_version: int,
|
||||
new_status: str,
|
||||
new_payload: Dict[str, Any],
|
||||
new_provenance: Any,
|
||||
) -> bool:
|
||||
cur.execute(
|
||||
"""
|
||||
update clan.clan_artifacts
|
||||
set status=%s,
|
||||
payload=%s::jsonb,
|
||||
provenance=%s::jsonb,
|
||||
version=version+1,
|
||||
updated_ts=now()
|
||||
where artifact_id=%s and version=%s
|
||||
""",
|
||||
(
|
||||
new_status,
|
||||
json.dumps(new_payload, ensure_ascii=False),
|
||||
json.dumps(new_provenance, ensure_ascii=False),
|
||||
artifact_id,
|
||||
expected_version,
|
||||
),
|
||||
)
|
||||
return cur.rowcount == 1
|
||||
|
||||
|
||||
def _insert_transition(
|
||||
cur,
|
||||
*,
|
||||
consent_event_id: str,
|
||||
decision_type: str,
|
||||
request_id: str,
|
||||
artifact_id: str,
|
||||
artifact_type: str,
|
||||
visibility_level: str,
|
||||
from_status: str,
|
||||
to_status: str,
|
||||
op: str,
|
||||
versions: Dict[str, Any],
|
||||
) -> None:
|
||||
transition_id = _id("tr", f"{consent_event_id}:{artifact_id}:{decision_type}:{to_status}:{op}")
|
||||
cur.execute(
|
||||
"""
|
||||
insert into clan.clan_state_transitions(
|
||||
transition_id, artifact_id, artifact_type, from_status, to_status, op,
|
||||
consent_event_id, decision_type, request_id, visibility_level, versions
|
||||
) values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s::jsonb)
|
||||
on conflict (transition_id) do nothing
|
||||
""",
|
||||
(
|
||||
transition_id,
|
||||
artifact_id,
|
||||
artifact_type,
|
||||
from_status,
|
||||
to_status,
|
||||
op,
|
||||
consent_event_id,
|
||||
decision_type,
|
||||
request_id,
|
||||
visibility_level,
|
||||
json.dumps(versions, ensure_ascii=False),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _validate_consent_min(consent_event: Dict[str, Any]) -> Tuple[bool, str]:
|
||||
try:
|
||||
decision_type = consent_event["decision"]["type"]
|
||||
if decision_type not in {"approve", "reject", "revoke"}:
|
||||
return False, "invalid decision.type"
|
||||
target_ids = (consent_event.get("target") or {}).get("artifact_ids") or []
|
||||
if not target_ids:
|
||||
return False, "empty target.artifact_ids"
|
||||
|
||||
expires_at = (consent_event.get("decision") or {}).get("expires_at")
|
||||
if expires_at is not None and int(expires_at) < _now_ts():
|
||||
return False, "consent expired"
|
||||
|
||||
if decision_type == "approve":
|
||||
confirmations = consent_event.get("confirmations") or []
|
||||
quorum = consent_event.get("quorum") or {}
|
||||
required = int(quorum.get("required", 1))
|
||||
present = int(quorum.get("present", 0))
|
||||
if len(confirmations) < required or present < required:
|
||||
return False, "quorum not met"
|
||||
return True, ""
|
||||
except Exception as e:
|
||||
return False, f"validation_error:{e}"
|
||||
|
||||
|
||||
def _apply_outbox(cur, outbox: OutboxRow) -> None:
|
||||
consent = _get_consent_event(cur, outbox.consent_event_id)
|
||||
if not consent:
|
||||
_set_outbox_error(cur, outbox.outbox_id, "STOP_CONSENT_EVENT_MISSING: consent not found")
|
||||
return
|
||||
|
||||
ok, err = _validate_consent_min(consent)
|
||||
if not ok:
|
||||
_set_outbox_error(cur, outbox.outbox_id, f"STOP_CONSENT_EVENT_INVALID: {err}")
|
||||
return
|
||||
|
||||
decision_type = (consent.get("decision") or {}).get("type")
|
||||
versions = {
|
||||
"constitution_version": str((consent.get("versions") or {}).get("constitution_version") or "unknown"),
|
||||
"protocol_version": str((consent.get("versions") or {}).get("protocol_version") or "unknown"),
|
||||
"router_guard_version": str((consent.get("versions") or {}).get("router_guard_version") or "unknown"),
|
||||
}
|
||||
|
||||
for artifact_id in outbox.target_artifact_ids:
|
||||
artifact = _get_artifact(cur, artifact_id)
|
||||
if not artifact:
|
||||
_set_outbox_error(cur, outbox.outbox_id, f"STOP_CONSENT_EVENT_MISSING: artifact {artifact_id} not found")
|
||||
return
|
||||
|
||||
if artifact["artifact_type"] not in CONSENT_TRANSITION_MAP:
|
||||
_set_outbox_error(cur, outbox.outbox_id, f"STOP_CONSENT_EVENT_INVALID: no transition for {artifact['artifact_type']}")
|
||||
return
|
||||
|
||||
if decision_type not in CONSENT_TRANSITION_MAP[artifact["artifact_type"]]:
|
||||
_set_outbox_error(cur, outbox.outbox_id, f"STOP_CONSENT_EVENT_INVALID: decision not mapped {decision_type}")
|
||||
return
|
||||
|
||||
from_status = artifact["status"]
|
||||
if from_status == "rejected" and decision_type == "approve":
|
||||
_set_outbox_error(cur, outbox.outbox_id, f"STOP_CONSENT_EVENT_INVALID: one-way violation {artifact_id}")
|
||||
return
|
||||
|
||||
provenance = artifact["provenance"]
|
||||
if _provenance_has_consent(provenance, outbox.consent_event_id):
|
||||
continue
|
||||
|
||||
to_status, op = CONSENT_TRANSITION_MAP[artifact["artifact_type"]][decision_type]
|
||||
trail = _build_trail(
|
||||
consent_event=consent,
|
||||
request_id=outbox.request_id,
|
||||
consent_event_id=outbox.consent_event_id,
|
||||
decision_type=decision_type,
|
||||
op=op,
|
||||
visibility_level=artifact["visibility_level"],
|
||||
versions=versions,
|
||||
)
|
||||
|
||||
payload = artifact["payload"]
|
||||
expected_version = int(artifact["version"])
|
||||
new_provenance = (provenance if isinstance(provenance, list) else []) + [trail]
|
||||
|
||||
success = False
|
||||
for _ in range(MAX_ARTIFACT_CAS_RETRIES):
|
||||
success = _cas_update_artifact(
|
||||
cur,
|
||||
artifact_id=artifact_id,
|
||||
expected_version=expected_version,
|
||||
new_status=to_status,
|
||||
new_payload=payload,
|
||||
new_provenance=new_provenance,
|
||||
)
|
||||
if success:
|
||||
break
|
||||
|
||||
current = _get_artifact(cur, artifact_id)
|
||||
if not current:
|
||||
break
|
||||
expected_version = int(current["version"])
|
||||
payload = current["payload"]
|
||||
provenance = current["provenance"]
|
||||
if _provenance_has_consent(provenance, outbox.consent_event_id):
|
||||
success = True
|
||||
break
|
||||
new_provenance = (provenance if isinstance(provenance, list) else []) + [trail]
|
||||
|
||||
if not success:
|
||||
_set_outbox_error(cur, outbox.outbox_id, f"CAS conflict exceeded for {artifact_id}")
|
||||
return
|
||||
|
||||
_insert_transition(
|
||||
cur,
|
||||
consent_event_id=outbox.consent_event_id,
|
||||
decision_type=decision_type,
|
||||
request_id=outbox.request_id,
|
||||
artifact_id=artifact_id,
|
||||
artifact_type=artifact["artifact_type"],
|
||||
visibility_level=artifact["visibility_level"],
|
||||
from_status=from_status,
|
||||
to_status=to_status,
|
||||
op=op,
|
||||
versions=versions,
|
||||
)
|
||||
|
||||
_mark_outbox_done(cur, outbox.outbox_id)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
while True:
|
||||
try:
|
||||
with psycopg.connect(PG_DSN, row_factory=dict_row) as conn:
|
||||
conn.autocommit = False
|
||||
with conn.cursor() as cur:
|
||||
outboxes = _fetch_pending_outbox(cur, BATCH_SIZE)
|
||||
if not outboxes:
|
||||
conn.rollback()
|
||||
time.sleep(POLL_INTERVAL)
|
||||
continue
|
||||
for ob in outboxes:
|
||||
_apply_outbox(cur, ob)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
time.sleep(POLL_INTERVAL)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
4
services/clan-consent-adapter/requirements.txt
Normal file
4
services/clan-consent-adapter/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.32.1
|
||||
pydantic==2.10.3
|
||||
psycopg[binary]==3.2.3
|
||||
6
services/clan-visibility-guard/Dockerfile
Normal file
6
services/clan-visibility-guard/Dockerfile
Normal file
@@ -0,0 +1,6 @@
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app.py .
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8112"]
|
||||
80
services/clan-visibility-guard/app.py
Normal file
80
services/clan-visibility-guard/app.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
LEVELS = ["public", "interclan", "incircle", "soulsafe", "sacred"]
|
||||
LEVEL_RANK = {k: i for i, k in enumerate(LEVELS)}
|
||||
SENSITIVE_KEYWORDS = [
|
||||
"child", "children", "minor", "health", "trauma", "violence", "abuse",
|
||||
"ребен", "дитин", "здоров", "травм", "насил",
|
||||
]
|
||||
|
||||
app = FastAPI(title="CLAN Visibility Guard", version="1.0.0")
|
||||
|
||||
|
||||
class VisibilityCheck(BaseModel):
|
||||
current_level: str
|
||||
requested_level: str
|
||||
|
||||
|
||||
class ClassifyRequest(BaseModel):
|
||||
text: str
|
||||
|
||||
|
||||
class RedactRequest(BaseModel):
|
||||
text: str
|
||||
target_level: str
|
||||
|
||||
|
||||
def _norm(level: str) -> str:
|
||||
v = (level or "").strip().lower()
|
||||
if v not in LEVEL_RANK:
|
||||
raise HTTPException(status_code=400, detail=f"invalid_visibility_level:{level}")
|
||||
return v
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> Dict[str, Any]:
|
||||
return {"status": "ok", "service": "clan-visibility-guard"}
|
||||
|
||||
|
||||
@app.post("/visibility/check_downgrade")
|
||||
def check_downgrade(body: VisibilityCheck) -> Dict[str, Any]:
|
||||
cur = _norm(body.current_level)
|
||||
req = _norm(body.requested_level)
|
||||
allowed = LEVEL_RANK[req] >= LEVEL_RANK[cur]
|
||||
return {
|
||||
"allowed": allowed,
|
||||
"reason": "ok" if allowed else "downgrade_requires_consent",
|
||||
"current_level": cur,
|
||||
"requested_level": req,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/visibility/classify")
|
||||
def classify(body: ClassifyRequest) -> Dict[str, Any]:
|
||||
t = (body.text or "").lower()
|
||||
flags: List[str] = [k for k in SENSITIVE_KEYWORDS if k in t]
|
||||
recommended = "soulsafe" if flags else "incircle"
|
||||
if any(x in t for x in ["sacred", "сакрал", "духовн"]):
|
||||
recommended = "sacred"
|
||||
return {
|
||||
"recommended_level": recommended,
|
||||
"sensitivity_flags": flags,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/visibility/redact_for_level")
|
||||
def redact_for_level(body: RedactRequest) -> Dict[str, Any]:
|
||||
target = _norm(body.target_level)
|
||||
txt = body.text or ""
|
||||
redacted = txt
|
||||
if target in {"public", "interclan", "incircle"}:
|
||||
for token in ["ребен", "дитин", "здоров", "травм", "насил", "health", "trauma", "violence", "abuse"]:
|
||||
redacted = redacted.replace(token, "[sensitive]")
|
||||
return {
|
||||
"target_level": target,
|
||||
"redacted_text": redacted,
|
||||
"changed": redacted != txt,
|
||||
}
|
||||
3
services/clan-visibility-guard/requirements.txt
Normal file
3
services/clan-visibility-guard/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.32.1
|
||||
pydantic==2.10.3
|
||||
9
services/oneok-calc-adapter/Dockerfile
Normal file
9
services/oneok-calc-adapter/Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app.py .
|
||||
|
||||
EXPOSE 8089
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8089"]
|
||||
96
services/oneok-calc-adapter/app.py
Normal file
96
services/oneok-calc-adapter/app.py
Normal file
@@ -0,0 +1,96 @@
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException
|
||||
|
||||
API_KEY = os.getenv("ONEOK_ADAPTER_API_KEY", "").strip()
|
||||
BASE_RATE_PER_M2 = float(os.getenv("ONEOK_BASE_RATE_PER_M2", "3200"))
|
||||
INSTALL_RATE_PER_M2 = float(os.getenv("ONEOK_INSTALL_RATE_PER_M2", "900"))
|
||||
CURRENCY = os.getenv("ONEOK_CURRENCY", "UAH")
|
||||
|
||||
app = FastAPI(title="1OK Calc Adapter", version="1.0.0")
|
||||
|
||||
|
||||
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 _normalize_units(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
units = payload.get("window_units")
|
||||
if isinstance(units, list) and units:
|
||||
return [u for u in units if isinstance(u, dict)]
|
||||
# Compatibility alias used by some LLM tool calls.
|
||||
units = payload.get("windows")
|
||||
if isinstance(units, list) and units:
|
||||
return [u for u in units if isinstance(u, dict)]
|
||||
# fallback to one unit in root payload
|
||||
if "width_mm" in payload and "height_mm" in payload:
|
||||
return [payload]
|
||||
return []
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> Dict[str, Any]:
|
||||
return {"status": "ok", "service": "oneok-calc-adapter"}
|
||||
|
||||
|
||||
@app.post("/calc/window_quote")
|
||||
def calc_window_quote(input_payload: Dict[str, Any], _: None = Depends(_auth)) -> Dict[str, Any]:
|
||||
units = _normalize_units(input_payload)
|
||||
if not units:
|
||||
raise HTTPException(status_code=400, detail="window_units/windows or width_mm/height_mm required")
|
||||
|
||||
line_items: List[Dict[str, Any]] = []
|
||||
subtotal = 0.0
|
||||
|
||||
for idx, unit in enumerate(units, 1):
|
||||
width_mm = float(unit.get("width_mm") or 0)
|
||||
height_mm = float(unit.get("height_mm") or 0)
|
||||
if width_mm <= 0 or height_mm <= 0:
|
||||
continue
|
||||
area_m2 = (width_mm * height_mm) / 1_000_000.0
|
||||
base = round(area_m2 * BASE_RATE_PER_M2, 2)
|
||||
install = round(area_m2 * INSTALL_RATE_PER_M2, 2)
|
||||
total = round(base + install, 2)
|
||||
subtotal += total
|
||||
|
||||
line_items.append(
|
||||
{
|
||||
"item": unit.get("label") or f"window_{idx}",
|
||||
"width_mm": width_mm,
|
||||
"height_mm": height_mm,
|
||||
"area_m2": round(area_m2, 3),
|
||||
"base_price": base,
|
||||
"install_price": install,
|
||||
"total": total,
|
||||
}
|
||||
)
|
||||
|
||||
if not line_items:
|
||||
raise HTTPException(status_code=400, detail="No valid window units for calculation")
|
||||
|
||||
assumptions = [
|
||||
f"Базова ставка: {BASE_RATE_PER_M2} {CURRENCY}/м2",
|
||||
f"Монтаж: {INSTALL_RATE_PER_M2} {CURRENCY}/м2",
|
||||
"ОЦІНКА: без виїзного підтвердженого заміру ціна попередня.",
|
||||
]
|
||||
|
||||
lead_days = 10
|
||||
if str(input_payload.get("urgency", "")).lower() in {"urgent", "терміново"}:
|
||||
lead_days = 7
|
||||
|
||||
return {
|
||||
"currency": CURRENCY,
|
||||
"line_items": line_items,
|
||||
"totals": {
|
||||
"subtotal": round(subtotal, 2),
|
||||
"discount": 0.0,
|
||||
"grand_total": round(subtotal, 2),
|
||||
},
|
||||
"assumptions": assumptions,
|
||||
"lead_time_if_known": f"{lead_days} днів",
|
||||
}
|
||||
2
services/oneok-calc-adapter/requirements.txt
Normal file
2
services/oneok-calc-adapter/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.32.1
|
||||
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
|
||||
13
services/oneok-docs-adapter/Dockerfile
Normal file
13
services/oneok-docs-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 8090
|
||||
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8090"]
|
||||
102
services/oneok-docs-adapter/app.py
Normal file
102
services/oneok-docs-adapter/app.py
Normal file
@@ -0,0 +1,102 @@
|
||||
import base64
|
||||
import html
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException
|
||||
|
||||
GOTENBERG_URL = os.getenv("ONEOK_GOTENBERG_URL", "http://oneok-gotenberg:3000").rstrip("/")
|
||||
API_KEY = os.getenv("ONEOK_ADAPTER_API_KEY", "").strip()
|
||||
|
||||
app = FastAPI(title="1OK Docs Adapter", version="1.0.0")
|
||||
|
||||
|
||||
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 _render_html(title: str, data: Dict[str, Any]) -> str:
|
||||
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
rows = []
|
||||
for k, v in data.items():
|
||||
rows.append(
|
||||
f"<tr><td style='padding:6px;border:1px solid #ddd;vertical-align:top'><b>{html.escape(str(k))}</b></td>"
|
||||
f"<td style='padding:6px;border:1px solid #ddd'>{html.escape(str(v))}</td></tr>"
|
||||
)
|
||||
table = "\n".join(rows) if rows else "<tr><td style='padding:6px;border:1px solid #ddd'>No data</td></tr>"
|
||||
return f"""<!doctype html>
|
||||
<html lang="uk">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; color: #111; margin: 24px; }}
|
||||
h1 {{ margin: 0 0 8px; font-size: 24px; }}
|
||||
.meta {{ color: #666; margin: 0 0 18px; font-size: 12px; }}
|
||||
table {{ border-collapse: collapse; width: 100%; }}
|
||||
td {{ font-size: 13px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{html.escape(title)}</h1>
|
||||
<div class="meta">Generated: {html.escape(ts)}</div>
|
||||
<table>{table}</table>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
async def _render_pdf(file_name: str, html_text: str) -> Dict[str, Any]:
|
||||
endpoints = [
|
||||
f"{GOTENBERG_URL}/forms/chromium/convert/html",
|
||||
f"{GOTENBERG_URL}/forms/libreoffice/convert",
|
||||
]
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
files = {file_name: (file_name, html_text.encode("utf-8"), "text/html")}
|
||||
last_error = None
|
||||
pdf = b""
|
||||
for url in endpoints:
|
||||
resp = await client.post(url, files=files)
|
||||
if resp.status_code < 400:
|
||||
pdf = resp.content
|
||||
break
|
||||
last_error = f"{url} -> {resp.status_code} {resp.text[:200]}"
|
||||
if not pdf:
|
||||
raise HTTPException(status_code=502, detail=f"Gotenberg error: {last_error}")
|
||||
return {
|
||||
"file_name": file_name.replace(".html", ".pdf"),
|
||||
"mime": "application/pdf",
|
||||
"size_bytes": len(pdf),
|
||||
"pdf_base64": base64.b64encode(pdf).decode("utf-8"),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> Dict[str, Any]:
|
||||
return {"status": "ok", "service": "oneok-docs-adapter"}
|
||||
|
||||
|
||||
@app.post("/docs/render_quote_pdf")
|
||||
async def render_quote_pdf(body: Dict[str, Any], _: None = Depends(_auth)) -> Dict[str, Any]:
|
||||
quote_id = body.get("quote_id")
|
||||
payload = body.get("quote_payload") or {}
|
||||
if not quote_id and not payload:
|
||||
raise HTTPException(status_code=400, detail="quote_id or quote_payload required")
|
||||
if quote_id and "quote_id" not in payload:
|
||||
payload = dict(payload)
|
||||
payload["quote_id"] = quote_id
|
||||
html_doc = _render_html("Комерційна пропозиція (1OK)", payload)
|
||||
return await _render_pdf("quote.html", html_doc)
|
||||
|
||||
|
||||
@app.post("/docs/render_invoice_pdf")
|
||||
async def render_invoice_pdf(body: Dict[str, Any], _: None = Depends(_auth)) -> Dict[str, Any]:
|
||||
payload = body.get("invoice_payload")
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(status_code=400, detail="invoice_payload required")
|
||||
html_doc = _render_html("Рахунок (1OK)", payload)
|
||||
return await _render_pdf("invoice.html", html_doc)
|
||||
3
services/oneok-docs-adapter/requirements.txt
Normal file
3
services/oneok-docs-adapter/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.32.1
|
||||
httpx==0.28.1
|
||||
9
services/oneok-schedule-adapter/Dockerfile
Normal file
9
services/oneok-schedule-adapter/Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app.py .
|
||||
|
||||
EXPOSE 8091
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8091"]
|
||||
67
services/oneok-schedule-adapter/app.py
Normal file
67
services/oneok-schedule-adapter/app.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException
|
||||
|
||||
API_KEY = os.getenv("ONEOK_ADAPTER_API_KEY", "").strip()
|
||||
DEFAULT_TZ = os.getenv("ONEOK_SCHEDULE_TZ", "Europe/Kyiv")
|
||||
|
||||
app = FastAPI(title="1OK Schedule Adapter", version="1.0.0")
|
||||
|
||||
|
||||
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 _next_work_slots(count: int = 3) -> List[Dict[str, Any]]:
|
||||
now = datetime.utcnow()
|
||||
slots: List[Dict[str, Any]] = []
|
||||
d = now
|
||||
while len(slots) < count:
|
||||
d += timedelta(days=1)
|
||||
if d.weekday() >= 5:
|
||||
continue
|
||||
day = d.strftime("%Y-%m-%d")
|
||||
for hour in (10, 13, 16):
|
||||
slots.append(
|
||||
{
|
||||
"slot_id": str(uuid.uuid4()),
|
||||
"start_local": f"{day}T{hour:02d}:00:00",
|
||||
"end_local": f"{day}T{hour+1:02d}:00:00",
|
||||
"timezone": DEFAULT_TZ,
|
||||
}
|
||||
)
|
||||
if len(slots) >= count:
|
||||
break
|
||||
return slots
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> Dict[str, Any]:
|
||||
return {"status": "ok", "service": "oneok-schedule-adapter", "timezone": DEFAULT_TZ}
|
||||
|
||||
|
||||
@app.post("/schedule/propose_slots")
|
||||
def propose_slots(params: Dict[str, Any], _: None = Depends(_auth)) -> Dict[str, Any]:
|
||||
count = int(params.get("count") or 3)
|
||||
count = 3 if count < 1 else min(count, 8)
|
||||
return {"slots": _next_work_slots(count=count)}
|
||||
|
||||
|
||||
@app.post("/schedule/confirm_slot")
|
||||
def confirm_slot(body: Dict[str, Any], _: None = Depends(_auth)) -> Dict[str, Any]:
|
||||
job_id = body.get("job_id")
|
||||
slot = body.get("slot")
|
||||
if not job_id or slot is None:
|
||||
raise HTTPException(status_code=400, detail="job_id and slot required")
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"confirmed_slot": slot,
|
||||
"status": "confirmed",
|
||||
}
|
||||
2
services/oneok-schedule-adapter/requirements.txt
Normal file
2
services/oneok-schedule-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