393 lines
14 KiB
Python
393 lines
14 KiB
Python
|
|
# -*- coding: utf-8 -*-
|
||
|
|
"""Client- und Server-Sync für private und veröffentlichte Doku-Prompts.
|
||
|
|
|
||
|
|
Private Prompts: erweitert aza_sync_items (item_type=doku_prompt).
|
||
|
|
Öffentliche Prompts: eigene SQLite-Tabelle published_doku_prompts.
|
||
|
|
|
||
|
|
Kein Deploy in diesem Block — Routen werden lokal registriert.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
import sqlite3
|
||
|
|
import time
|
||
|
|
from datetime import datetime, timezone
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Any, Dict, List, Optional, Tuple
|
||
|
|
|
||
|
|
from fastapi import Depends, HTTPException, Request
|
||
|
|
from pydantic import BaseModel, Field
|
||
|
|
|
||
|
|
from aza_security import require_api_token
|
||
|
|
|
||
|
|
MAX_PROMPT_CHARS = 32000
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Server DB
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
def ensure_published_doku_prompts_schema(con: sqlite3.Connection) -> None:
|
||
|
|
con.execute(
|
||
|
|
"""
|
||
|
|
CREATE TABLE IF NOT EXISTS published_doku_prompts (
|
||
|
|
id TEXT PRIMARY KEY,
|
||
|
|
user_id TEXT NOT NULL,
|
||
|
|
practice_id TEXT NOT NULL,
|
||
|
|
doc_type TEXT NOT NULL,
|
||
|
|
title TEXT NOT NULL,
|
||
|
|
description TEXT,
|
||
|
|
content TEXT NOT NULL,
|
||
|
|
author_display TEXT,
|
||
|
|
revision INTEGER DEFAULT 1,
|
||
|
|
status TEXT DEFAULT 'published',
|
||
|
|
language TEXT,
|
||
|
|
specialty TEXT,
|
||
|
|
created_at INTEGER,
|
||
|
|
updated_at INTEGER
|
||
|
|
)
|
||
|
|
"""
|
||
|
|
)
|
||
|
|
con.execute(
|
||
|
|
"CREATE INDEX IF NOT EXISTS idx_pub_doku_type_status "
|
||
|
|
"ON published_doku_prompts(doc_type, status, updated_at)"
|
||
|
|
)
|
||
|
|
con.commit()
|
||
|
|
|
||
|
|
|
||
|
|
def _now_ts() -> int:
|
||
|
|
return int(time.time())
|
||
|
|
|
||
|
|
|
||
|
|
def _sanitize_server_text(text: str) -> str:
|
||
|
|
from aza_doku_vorlagen import sanitize_prompt_text
|
||
|
|
return sanitize_prompt_text(text)
|
||
|
|
|
||
|
|
|
||
|
|
def _resolve_auth_context(con: sqlite3.Connection, request: Request) -> Tuple[str, str]:
|
||
|
|
from aza_sync_items import _resolve_practice_id_for_request
|
||
|
|
|
||
|
|
practice_id = _resolve_practice_id_for_request(con, request)
|
||
|
|
user_id = (request.headers.get("X-User-Id") or request.headers.get("X-Empfang-User-Id") or "").strip()
|
||
|
|
if not user_id:
|
||
|
|
raise HTTPException(status_code=403, detail="user_id required")
|
||
|
|
if practice_id == "default":
|
||
|
|
raise HTTPException(status_code=403, detail="invalid practice_id")
|
||
|
|
return user_id, practice_id
|
||
|
|
|
||
|
|
|
||
|
|
class PublishDokuPromptIn(BaseModel):
|
||
|
|
doc_type: str
|
||
|
|
title: str = Field(min_length=1, max_length=200)
|
||
|
|
description: str = Field(default="", max_length=1000)
|
||
|
|
content: str = Field(min_length=1, max_length=MAX_PROMPT_CHARS)
|
||
|
|
author_display: str = Field(default="", max_length=120)
|
||
|
|
server_id: Optional[str] = ""
|
||
|
|
language: Optional[str] = ""
|
||
|
|
specialty: Optional[str] = ""
|
||
|
|
|
||
|
|
|
||
|
|
def register_doku_prompt_routes(app, *, stripe_db_path_fn) -> None:
|
||
|
|
"""Registriert /v1/doku-prompts/* — lokal, Deploy separat."""
|
||
|
|
|
||
|
|
@app.get("/v1/doku-prompts/public", dependencies=[Depends(require_api_token)])
|
||
|
|
def doku_prompts_public_list(request: Request):
|
||
|
|
db_path = stripe_db_path_fn()
|
||
|
|
if not Path(db_path).exists():
|
||
|
|
raise HTTPException(status_code=503, detail="database missing")
|
||
|
|
with sqlite3.connect(db_path) as con:
|
||
|
|
ensure_published_doku_prompts_schema(con)
|
||
|
|
_resolve_auth_context(con, request)
|
||
|
|
rows = con.execute(
|
||
|
|
"""
|
||
|
|
SELECT id, doc_type, title, description, content, author_display,
|
||
|
|
revision, updated_at
|
||
|
|
FROM published_doku_prompts
|
||
|
|
WHERE status = 'published'
|
||
|
|
ORDER BY doc_type, updated_at DESC
|
||
|
|
"""
|
||
|
|
).fetchall()
|
||
|
|
items = [
|
||
|
|
{
|
||
|
|
"id": r[0],
|
||
|
|
"doc_type": r[1],
|
||
|
|
"title": r[2],
|
||
|
|
"description": r[3] or "",
|
||
|
|
"content": r[4],
|
||
|
|
"author_display": r[5] or "",
|
||
|
|
"revision": int(r[6] or 1),
|
||
|
|
"updated_at": int(r[7] or 0),
|
||
|
|
}
|
||
|
|
for r in rows
|
||
|
|
]
|
||
|
|
return {"ok": True, "items": items, "count": len(items)}
|
||
|
|
|
||
|
|
@app.post("/v1/doku-prompts/publish", dependencies=[Depends(require_api_token)])
|
||
|
|
def doku_prompts_publish(request: Request, body: PublishDokuPromptIn):
|
||
|
|
db_path = stripe_db_path_fn()
|
||
|
|
if not Path(db_path).exists():
|
||
|
|
raise HTTPException(status_code=503, detail="database missing")
|
||
|
|
content = _sanitize_server_text(body.content)
|
||
|
|
if not content:
|
||
|
|
raise HTTPException(status_code=400, detail="empty content")
|
||
|
|
with sqlite3.connect(db_path) as con:
|
||
|
|
ensure_published_doku_prompts_schema(con)
|
||
|
|
user_id, practice_id = _resolve_auth_context(con, request)
|
||
|
|
now = _now_ts()
|
||
|
|
pub_id = (body.server_id or "").strip() or f"pub_{user_id}_{now}"
|
||
|
|
existing = con.execute(
|
||
|
|
"SELECT user_id, revision, created_at FROM published_doku_prompts WHERE id = ?",
|
||
|
|
(pub_id,),
|
||
|
|
).fetchone()
|
||
|
|
if existing and existing[0] != user_id:
|
||
|
|
raise HTTPException(status_code=403, detail="not owner")
|
||
|
|
rev = int(existing[1] or 0) + 1 if existing else 1
|
||
|
|
created_at = int(existing[2] or now) if existing else now
|
||
|
|
con.execute(
|
||
|
|
"""
|
||
|
|
INSERT INTO published_doku_prompts
|
||
|
|
(id, user_id, practice_id, doc_type, title, description, content,
|
||
|
|
author_display, revision, status, language, specialty, created_at, updated_at)
|
||
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'published', ?, ?, ?, ?)
|
||
|
|
ON CONFLICT(id) DO UPDATE SET
|
||
|
|
doc_type=excluded.doc_type, title=excluded.title, description=excluded.description,
|
||
|
|
content=excluded.content, author_display=excluded.author_display,
|
||
|
|
revision=excluded.revision, status='published',
|
||
|
|
language=excluded.language, specialty=excluded.specialty,
|
||
|
|
updated_at=excluded.updated_at
|
||
|
|
""",
|
||
|
|
(
|
||
|
|
pub_id, user_id, practice_id, body.doc_type.strip(),
|
||
|
|
body.title.strip(), body.description.strip(), content,
|
||
|
|
body.author_display.strip(), rev,
|
||
|
|
(body.language or "").strip(), (body.specialty or "").strip(),
|
||
|
|
created_at, now,
|
||
|
|
),
|
||
|
|
)
|
||
|
|
con.commit()
|
||
|
|
return {"ok": True, "id": pub_id, "revision": rev}
|
||
|
|
|
||
|
|
@app.post("/v1/doku-prompts/unpublish/{pub_id}", dependencies=[Depends(require_api_token)])
|
||
|
|
def doku_prompts_unpublish(pub_id: str, request: Request):
|
||
|
|
db_path = stripe_db_path_fn()
|
||
|
|
with sqlite3.connect(db_path) as con:
|
||
|
|
ensure_published_doku_prompts_schema(con)
|
||
|
|
user_id, _ = _resolve_auth_context(con, request)
|
||
|
|
row = con.execute(
|
||
|
|
"SELECT user_id FROM published_doku_prompts WHERE id = ?", (pub_id,),
|
||
|
|
).fetchone()
|
||
|
|
if not row:
|
||
|
|
raise HTTPException(status_code=404, detail="not found")
|
||
|
|
if row[0] != user_id:
|
||
|
|
raise HTTPException(status_code=403, detail="not owner")
|
||
|
|
con.execute(
|
||
|
|
"UPDATE published_doku_prompts SET status='unpublished', updated_at=? WHERE id=?",
|
||
|
|
(_now_ts(), pub_id),
|
||
|
|
)
|
||
|
|
con.commit()
|
||
|
|
return {"ok": True, "id": pub_id, "status": "unpublished"}
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Client
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
def _sync_headers(app) -> Optional[Dict[str, str]]:
|
||
|
|
try:
|
||
|
|
from aza_sync_items import _sync_headers
|
||
|
|
h = _sync_headers(app)
|
||
|
|
if not h:
|
||
|
|
return None
|
||
|
|
uid = ""
|
||
|
|
if hasattr(app, "_empfang_self_user_id"):
|
||
|
|
uid = (app._empfang_self_user_id() or "").strip()
|
||
|
|
if uid:
|
||
|
|
h["X-User-Id"] = uid
|
||
|
|
h["X-Empfang-User-Id"] = uid
|
||
|
|
return h
|
||
|
|
except Exception:
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def _http_get(url: str, headers: Dict[str, str]) -> Tuple[int, Any]:
|
||
|
|
import requests
|
||
|
|
r = requests.get(url, headers=headers, timeout=(3, 15))
|
||
|
|
try:
|
||
|
|
return r.status_code, r.json()
|
||
|
|
except Exception:
|
||
|
|
return r.status_code, {}
|
||
|
|
|
||
|
|
|
||
|
|
def _http_post(url: str, headers: Dict[str, str], body: Any) -> Tuple[int, Any]:
|
||
|
|
import requests
|
||
|
|
r = requests.post(url, headers=headers, json=body, timeout=(3, 20))
|
||
|
|
try:
|
||
|
|
return r.status_code, r.json()
|
||
|
|
except Exception:
|
||
|
|
return r.status_code, {}
|
||
|
|
|
||
|
|
|
||
|
|
def local_private_templates_to_sync_items(data: dict) -> List[Dict[str, Any]]:
|
||
|
|
"""Serialisiert nicht-system Vorlagen für Sync."""
|
||
|
|
items = []
|
||
|
|
order = 0
|
||
|
|
for doc_type, tpls in (data.get("templates") or {}).items():
|
||
|
|
if not isinstance(tpls, list):
|
||
|
|
continue
|
||
|
|
for t in tpls:
|
||
|
|
if not isinstance(t, dict) or t.get("is_system"):
|
||
|
|
continue
|
||
|
|
order += 1
|
||
|
|
payload = {
|
||
|
|
"doc_type": doc_type,
|
||
|
|
"name": t.get("name") or "",
|
||
|
|
"content": t.get("content") or "",
|
||
|
|
"description": t.get("description") or "",
|
||
|
|
"revision": int(t.get("revision") or 1),
|
||
|
|
"updated_at": t.get("updated_at") or "",
|
||
|
|
"server_id": t.get("server_id") or "",
|
||
|
|
}
|
||
|
|
items.append(
|
||
|
|
{
|
||
|
|
"id": t.get("id") or f"doku_{doc_type}_{order}",
|
||
|
|
"item_type": "doku_prompt",
|
||
|
|
"title": t.get("name") or doc_type,
|
||
|
|
"trigger": doc_type,
|
||
|
|
"content": json.dumps(payload, ensure_ascii=False),
|
||
|
|
"sort_order": order,
|
||
|
|
"is_active": True,
|
||
|
|
"created_by": t.get("user_id") or "",
|
||
|
|
"updated_by": t.get("user_id") or "",
|
||
|
|
}
|
||
|
|
)
|
||
|
|
return items
|
||
|
|
|
||
|
|
|
||
|
|
def merge_server_doku_items_local(data: dict, server_items: List[Dict[str, Any]]) -> tuple[dict, bool]:
|
||
|
|
"""Neueste Revision gewinnt; Konflikte werden markiert."""
|
||
|
|
changed = False
|
||
|
|
conflicts = data.setdefault("sync_meta", {}).setdefault("conflicts", [])
|
||
|
|
for it in server_items:
|
||
|
|
if (it.get("item_type") or "") != "doku_prompt":
|
||
|
|
continue
|
||
|
|
try:
|
||
|
|
payload = json.loads(it.get("content") or "{}")
|
||
|
|
except Exception:
|
||
|
|
continue
|
||
|
|
doc_type = payload.get("doc_type") or it.get("trigger") or ""
|
||
|
|
if not doc_type:
|
||
|
|
continue
|
||
|
|
tpl_id = it.get("id") or ""
|
||
|
|
if not tpl_id:
|
||
|
|
continue
|
||
|
|
local_tpls = data.setdefault("templates", {}).setdefault(doc_type, [])
|
||
|
|
local = next((x for x in local_tpls if isinstance(x, dict) and x.get("id") == tpl_id), None)
|
||
|
|
srv_rev = int(payload.get("revision") or 0)
|
||
|
|
loc_rev = int(local.get("revision") or 0) if local else 0
|
||
|
|
srv_ts = payload.get("updated_at") or ""
|
||
|
|
loc_ts = local.get("updated_at") or "" if local else ""
|
||
|
|
if local and loc_rev > srv_rev and loc_ts > srv_ts:
|
||
|
|
conflicts.append({"id": tpl_id, "doc_type": doc_type, "at": datetime.now(timezone.utc).isoformat()})
|
||
|
|
continue
|
||
|
|
entry = {
|
||
|
|
"id": tpl_id,
|
||
|
|
"name": payload.get("name") or it.get("title") or "Sync-Vorlage",
|
||
|
|
"is_system": False,
|
||
|
|
"content": payload.get("content") or "",
|
||
|
|
"description": payload.get("description") or "",
|
||
|
|
"created_at": local.get("created_at") if local else srv_ts,
|
||
|
|
"updated_at": srv_ts or _utc_now_iso(),
|
||
|
|
"revision": srv_rev or 1,
|
||
|
|
"user_id": it.get("created_by") or "",
|
||
|
|
"practice_id": "",
|
||
|
|
"server_id": payload.get("server_id") or "",
|
||
|
|
"published": bool(local.get("published")) if local else False,
|
||
|
|
"sync_updated_at": srv_ts,
|
||
|
|
"source_author": local.get("source_author", "") if local else "",
|
||
|
|
"source_server_id": local.get("source_server_id", "") if local else "",
|
||
|
|
}
|
||
|
|
if local:
|
||
|
|
local.update(entry)
|
||
|
|
else:
|
||
|
|
local_tpls.append(entry)
|
||
|
|
changed = True
|
||
|
|
if changed:
|
||
|
|
data["sync_meta"]["last_sync_at"] = _utc_now_iso()
|
||
|
|
return data, changed
|
||
|
|
|
||
|
|
|
||
|
|
def _utc_now_iso() -> str:
|
||
|
|
return datetime.now(timezone.utc).isoformat()
|
||
|
|
|
||
|
|
|
||
|
|
def sync_doku_prompts_with_server(app) -> bool:
|
||
|
|
from aza_doku_vorlagen import _load, _save
|
||
|
|
from aza_sync_items import _fetch_server_items, _upload_items_bulk
|
||
|
|
|
||
|
|
headers = _sync_headers(app)
|
||
|
|
if not headers:
|
||
|
|
return False
|
||
|
|
server_items = _fetch_server_items(app, "doku_prompt")
|
||
|
|
if server_items is None:
|
||
|
|
return False
|
||
|
|
data = _load()
|
||
|
|
if server_items:
|
||
|
|
data, changed = merge_server_doku_items_local(data, server_items)
|
||
|
|
if changed:
|
||
|
|
_save(data)
|
||
|
|
local_items = local_private_templates_to_sync_items(data)
|
||
|
|
if local_items:
|
||
|
|
return _upload_items_bulk(app, local_items)
|
||
|
|
return True
|
||
|
|
|
||
|
|
|
||
|
|
def fetch_public_doku_prompts(app) -> List[Dict[str, Any]]:
|
||
|
|
headers = _sync_headers(app)
|
||
|
|
if not headers:
|
||
|
|
return []
|
||
|
|
try:
|
||
|
|
bu = app.get_backend_url().rstrip("/")
|
||
|
|
except Exception:
|
||
|
|
return []
|
||
|
|
code, data = _http_get(f"{bu}/v1/doku-prompts/public", headers)
|
||
|
|
if code != 200 or not isinstance(data, dict) or not data.get("ok"):
|
||
|
|
return []
|
||
|
|
items = data.get("items")
|
||
|
|
return items if isinstance(items, list) else []
|
||
|
|
|
||
|
|
|
||
|
|
def publish_doku_prompt(
|
||
|
|
app, doc_type: str, tpl: dict, title: str, description: str = "",
|
||
|
|
) -> Tuple[bool, str]:
|
||
|
|
headers = _sync_headers(app)
|
||
|
|
if not headers:
|
||
|
|
return False, "Nicht angemeldet oder keine Praxis-Zuordnung."
|
||
|
|
try:
|
||
|
|
bu = app.get_backend_url().rstrip("/")
|
||
|
|
except Exception:
|
||
|
|
return False, "Backend nicht konfiguriert."
|
||
|
|
author = ""
|
||
|
|
try:
|
||
|
|
from aza_persistence import load_signature_name
|
||
|
|
author = load_signature_name() or ""
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
body = {
|
||
|
|
"doc_type": doc_type,
|
||
|
|
"title": title,
|
||
|
|
"description": description,
|
||
|
|
"content": tpl.get("content") or "",
|
||
|
|
"author_display": author,
|
||
|
|
"server_id": tpl.get("server_id") or tpl.get("id") or "",
|
||
|
|
}
|
||
|
|
code, data = _http_post(f"{bu}/v1/doku-prompts/publish", headers, body)
|
||
|
|
if code == 200 and isinstance(data, dict) and data.get("ok"):
|
||
|
|
return True, ""
|
||
|
|
detail = ""
|
||
|
|
if isinstance(data, dict):
|
||
|
|
detail = str(data.get("detail") or data.get("message") or "")
|
||
|
|
return False, detail or f"Server-Fehler ({code})"
|