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