# -*- 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})"