Files
aza/AzA march 2026/aza_sync_items.py

784 lines
26 KiB
Python
Raw Normal View History

2026-05-23 21:31:34 +02:00
# -*- coding: utf-8 -*-
"""Server- und Client-Sync für Textblöcke, Korrekturen und Autotext (pro practice_id).
Parallele Supabase-Workspace-Sync (aza_workspace_sync) bleibt unverändert.
Dieses Modul nutzt dieselbe API wie KI-Guthaben (stripe_webhook.sqlite + X-Practice-Id).
"""
from __future__ import annotations
import hashlib
import json
import shutil
import sqlite3
import threading
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from fastapi import Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field
from aza_security import require_api_token
from aza_ai_budget import resolve_license_for_empfang
SYNC_ITEM_TYPES = ("textblock", "correction", "autotext")
_PUSH_DELAY_SEC = 0.8
_push_lock = threading.Lock()
_push_timers: Dict[str, threading.Timer] = {}
# ---------------------------------------------------------------------------
# Logging (keine Inhalte)
# ---------------------------------------------------------------------------
def _client_debug_log(line: str) -> None:
try:
from aza_config import get_writable_data_dir
path = Path(get_writable_data_dir()) / "client_debug.log"
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
with open(path, "a", encoding="utf-8") as f:
f.write(f"{ts} {line}\n")
except Exception:
pass
def _log_sync(event: str, **fields: Any) -> None:
parts = [str(event)]
for k, v in fields.items():
kl = str(k).lower()
if any(x in kl for x in ("content", "text", "body", "trigger", "title", "token", "secret")):
continue
parts.append(f"{k}={v}")
_client_debug_log(" ".join(parts))
# ---------------------------------------------------------------------------
# Server DB
# ---------------------------------------------------------------------------
def ensure_sync_items_schema(con: sqlite3.Connection) -> None:
con.execute(
"""
CREATE TABLE IF NOT EXISTS synced_user_items (
id TEXT PRIMARY KEY,
practice_id TEXT NOT NULL,
item_type TEXT NOT NULL,
title TEXT,
trigger_text TEXT,
content TEXT NOT NULL,
sort_order INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
created_at INTEGER,
updated_at INTEGER,
created_by TEXT,
updated_by TEXT,
source_device TEXT
)
"""
)
con.execute(
"CREATE INDEX IF NOT EXISTS idx_sync_items_practice_type "
"ON synced_user_items(practice_id, item_type, is_active, sort_order)"
)
con.commit()
def _now_ts() -> int:
return int(time.time())
def _resolve_practice_id_for_request(
con: sqlite3.Connection,
request: Request,
) -> str:
hdr = (request.headers.get("X-Practice-Id") or "").strip()
device_id = (request.headers.get("X-Device-Id") or "").strip() or None
lic = resolve_license_for_empfang(
con,
x_device_id=device_id,
session_practice_id=hdr or None,
)
if not lic:
raise HTTPException(status_code=403, detail="No license mapping for practice")
pid = (lic.practice_id or "").strip()
if not pid:
raise HTTPException(status_code=403, detail="practice_id missing on license")
if hdr and pid != hdr:
raise HTTPException(status_code=403, detail="practice_id mismatch")
return pid
def _row_to_item(row: tuple) -> Dict[str, Any]:
return {
"id": row[0],
"practice_id": row[1],
"item_type": row[2],
"title": row[3] or "",
"trigger": row[4] or "",
"content": row[5] or "",
"sort_order": int(row[6] or 0),
"is_active": bool(row[7]),
"created_at": int(row[8] or 0),
"updated_at": int(row[9] or 0),
"created_by": row[10] or "",
"updated_by": row[11] or "",
"source_device": row[12] or "",
}
def list_sync_items(
con: sqlite3.Connection,
practice_id: str,
item_type: str,
*,
active_only: bool = True,
) -> List[Dict[str, Any]]:
it = (item_type or "").strip().lower()
if it not in SYNC_ITEM_TYPES:
raise HTTPException(status_code=400, detail="invalid item_type")
q = """
SELECT id, practice_id, item_type, title, trigger_text, content,
sort_order, is_active, created_at, updated_at,
created_by, updated_by, source_device
FROM synced_user_items
WHERE practice_id = ? AND item_type = ?
"""
params: List[Any] = [practice_id, it]
if active_only:
q += " AND is_active = 1"
q += " ORDER BY sort_order ASC, updated_at DESC"
rows = con.execute(q, params).fetchall()
return [_row_to_item(r) for r in rows]
def upsert_sync_item(
con: sqlite3.Connection,
practice_id: str,
payload: Dict[str, Any],
*,
source_device: str = "",
) -> Dict[str, Any]:
it = str(payload.get("item_type") or "").strip().lower()
if it not in SYNC_ITEM_TYPES:
raise HTTPException(status_code=400, detail="invalid item_type")
item_id = str(payload.get("id") or "").strip() or str(uuid.uuid4())
now = _now_ts()
existing = con.execute(
"SELECT practice_id, created_at FROM synced_user_items WHERE id = ?",
(item_id,),
).fetchone()
if existing and str(existing[0]) != practice_id:
raise HTTPException(status_code=403, detail="item belongs to another practice")
created_at = int(existing[1]) if existing else now
title = str(payload.get("title") or "")[:500]
trigger = str(payload.get("trigger") or "")[:500]
content = str(payload.get("content") or "")
sort_order = int(payload.get("sort_order") or 0)
is_active = 1 if payload.get("is_active", True) else 0
con.execute(
"""
INSERT INTO synced_user_items (
id, practice_id, item_type, title, trigger_text, content,
sort_order, is_active, created_at, updated_at,
created_by, updated_by, source_device
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
practice_id=excluded.practice_id,
item_type=excluded.item_type,
title=excluded.title,
trigger_text=excluded.trigger_text,
content=excluded.content,
sort_order=excluded.sort_order,
is_active=excluded.is_active,
updated_at=excluded.updated_at,
updated_by=excluded.updated_by,
source_device=excluded.source_device
""",
(
item_id,
practice_id,
it,
title,
trigger,
content,
sort_order,
is_active,
created_at,
now,
str(payload.get("created_by") or "")[:120],
str(payload.get("updated_by") or "")[:120],
(source_device or "")[:64],
),
)
con.commit()
row = con.execute(
"""
SELECT id, practice_id, item_type, title, trigger_text, content,
sort_order, is_active, created_at, updated_at,
created_by, updated_by, source_device
FROM synced_user_items WHERE id = ?
""",
(item_id,),
).fetchone()
return _row_to_item(row) if row else {"id": item_id}
def soft_delete_sync_item(
con: sqlite3.Connection,
practice_id: str,
item_id: str,
) -> bool:
cur = con.execute(
"""
UPDATE synced_user_items
SET is_active = 0, updated_at = ?
WHERE id = ? AND practice_id = ?
""",
(_now_ts(), item_id, practice_id),
)
con.commit()
return cur.rowcount > 0
# ---------------------------------------------------------------------------
# API models + routes
# ---------------------------------------------------------------------------
class SyncItemIn(BaseModel):
id: Optional[str] = None
item_type: str = Field(..., min_length=1)
title: Optional[str] = ""
trigger: Optional[str] = ""
content: str = ""
sort_order: int = 0
is_active: bool = True
created_by: Optional[str] = ""
updated_by: Optional[str] = ""
class SyncItemBulkIn(BaseModel):
items: List[SyncItemIn] = Field(default_factory=list)
def register_sync_items_routes(app, *, stripe_db_path_fn) -> None:
"""Registriert /v1/sync/items* auf der FastAPI-App."""
@app.get("/v1/sync/items", dependencies=[Depends(require_api_token)])
def sync_items_list(
request: Request,
type: str = Query(..., alias="type"),
):
db_path = stripe_db_path_fn()
if not db_path.exists():
raise HTTPException(status_code=503, detail="database missing")
with sqlite3.connect(db_path) as con:
ensure_sync_items_schema(con)
pid = _resolve_practice_id_for_request(con, request)
items = list_sync_items(con, pid, type)
return {"ok": True, "practice_id": pid, "item_type": type, "items": items, "count": len(items)}
@app.post("/v1/sync/items", dependencies=[Depends(require_api_token)])
def sync_items_upsert(request: Request, body: SyncItemIn):
db_path = stripe_db_path_fn()
if not db_path.exists():
raise HTTPException(status_code=503, detail="database missing")
dev = (request.headers.get("X-Device-Id") or "").strip()
with sqlite3.connect(db_path) as con:
ensure_sync_items_schema(con)
pid = _resolve_practice_id_for_request(con, request)
item = upsert_sync_item(
con,
pid,
body.model_dump(),
source_device=dev,
)
return {"ok": True, "item": item}
@app.post("/v1/sync/items/bulk", dependencies=[Depends(require_api_token)])
def sync_items_bulk(request: Request, body: SyncItemBulkIn):
db_path = stripe_db_path_fn()
if not db_path.exists():
raise HTTPException(status_code=503, detail="database missing")
dev = (request.headers.get("X-Device-Id") or "").strip()
saved = []
with sqlite3.connect(db_path) as con:
ensure_sync_items_schema(con)
pid = _resolve_practice_id_for_request(con, request)
for raw in body.items:
saved.append(
upsert_sync_item(
con,
pid,
raw.model_dump(),
source_device=dev,
)
)
return {"ok": True, "count": len(saved)}
@app.put("/v1/sync/items/{item_id}", dependencies=[Depends(require_api_token)])
def sync_items_put(item_id: str, request: Request, body: SyncItemIn):
db_path = stripe_db_path_fn()
payload = body.model_dump()
payload["id"] = item_id
dev = (request.headers.get("X-Device-Id") or "").strip()
with sqlite3.connect(db_path) as con:
ensure_sync_items_schema(con)
pid = _resolve_practice_id_for_request(con, request)
item = upsert_sync_item(con, pid, payload, source_device=dev)
return {"ok": True, "item": item}
@app.delete("/v1/sync/items/{item_id}", dependencies=[Depends(require_api_token)])
def sync_items_delete(item_id: str, request: Request):
db_path = stripe_db_path_fn()
with sqlite3.connect(db_path) as con:
ensure_sync_items_schema(con)
pid = _resolve_practice_id_for_request(con, request)
ok = soft_delete_sync_item(con, pid, item_id)
if not ok:
raise HTTPException(status_code=404, detail="not found")
return {"ok": True, "id": item_id}
@app.get("/v1/textblocks", dependencies=[Depends(require_api_token)])
def textblocks_list(request: Request):
return sync_items_list(request, type="textblock")
@app.post("/v1/textblocks", dependencies=[Depends(require_api_token)])
def textblocks_post(request: Request, body: SyncItemIn):
body.item_type = "textblock"
return sync_items_upsert(request, body)
# ---------------------------------------------------------------------------
# Client: local <-> server mapping
# ---------------------------------------------------------------------------
def _stable_id(prefix: str, *parts: str) -> str:
raw = "|".join([prefix] + [str(p) for p in parts])
return f"{prefix}_{hashlib.sha256(raw.encode('utf-8')).hexdigest()[:24]}"
def local_textblocks_to_items(tb: Dict[str, Any]) -> List[Dict[str, Any]]:
items = []
for sk in sorted(tb.keys(), key=lambda x: int(str(x)) if str(x).isdigit() else 0):
if not str(sk).isdigit():
continue
slot = tb[sk]
if not isinstance(slot, dict):
continue
items.append(
{
"id": _stable_id("tb", sk),
"item_type": "textblock",
"title": (slot.get("name") or "").strip() or f"Textblock {sk}",
"trigger": "",
"content": str(slot.get("content") or ""),
"sort_order": int(sk),
}
)
return items
def items_to_local_textblocks(items: List[Dict[str, Any]]) -> Dict[str, Dict[str, str]]:
out: Dict[str, Dict[str, str]] = {}
for it in sorted(items, key=lambda x: int(x.get("sort_order") or 0)):
sk = str(int(it.get("sort_order") or len(out) + 1))
out[sk] = {
"name": (it.get("title") or "").strip() or f"Textblock {sk}",
"content": str(it.get("content") or ""),
"updated_at": datetime.now(timezone.utc).isoformat(),
}
if len(out) < 2:
out.setdefault("1", {"name": "Textblock 1", "content": "", "updated_at": ""})
out.setdefault("2", {"name": "Textblock 2", "content": "", "updated_at": ""})
return out
def local_korrekturen_to_items(k: Dict[str, Any]) -> List[Dict[str, Any]]:
items = []
order = 0
for cat, mapping in (k or {}).items():
if not isinstance(mapping, dict):
continue
for falsch, richtig in mapping.items():
order += 1
items.append(
{
"id": _stable_id("corr", cat, falsch),
"item_type": "correction",
"title": str(cat),
"trigger": str(falsch),
"content": str(richtig),
"sort_order": order,
}
)
return items
def items_to_local_korrekturen(items: List[Dict[str, Any]]) -> Dict[str, Dict[str, str]]:
out: Dict[str, Dict[str, str]] = {}
for it in items:
cat = (it.get("title") or "allgemein").strip() or "allgemein"
falsch = str(it.get("trigger") or "")
richtig = str(it.get("content") or "")
if not falsch:
continue
out.setdefault(cat, {})[falsch] = richtig
if not out:
try:
from aza_config import _DEFAULT_KORREKTUREN
return {c: dict(m) for c, m in _DEFAULT_KORREKTUREN.items()}
except Exception:
return {}
return out
def local_autotext_to_items(at: Dict[str, Any]) -> List[Dict[str, Any]]:
entries = at.get("entries") if isinstance(at.get("entries"), dict) else {}
items = []
order = 0
for abbr, text in entries.items():
if not isinstance(abbr, str) or not abbr.strip():
continue
order += 1
val = text if isinstance(text, str) else str(text)
items.append(
{
"id": _stable_id("at", abbr.strip()),
"item_type": "autotext",
"title": abbr.strip(),
"trigger": abbr.strip(),
"content": val,
"sort_order": order,
}
)
return items
def items_to_local_autotext_entries(items: List[Dict[str, Any]]) -> Dict[str, str]:
out: Dict[str, str] = {}
for it in items:
tr = (it.get("trigger") or it.get("title") or "").strip()
if tr:
out[tr] = str(it.get("content") or "")
return out
def _backup_local_sync_files() -> None:
try:
from aza_config import (
AUTOTEXT_CONFIG_FILENAME,
KORREKTUREN_CONFIG_FILENAME,
TEXTBLOECKE_CONFIG_FILENAME,
get_writable_data_dir,
)
base = Path(get_writable_data_dir())
stamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
dest = base / f".aza_sync_local_backup_{stamp}"
dest.mkdir(parents=True, exist_ok=True)
for name in (
TEXTBLOECKE_CONFIG_FILENAME,
KORREKTUREN_CONFIG_FILENAME,
AUTOTEXT_CONFIG_FILENAME,
):
src = base / name
if src.is_file():
shutil.copy2(src, dest / name)
except Exception:
pass
def _http_get_json(url: str, headers: Dict[str, str], timeout: Tuple[float, float]) -> Tuple[int, Any]:
import requests
r = requests.get(url, headers=headers, timeout=timeout)
try:
return r.status_code, r.json()
except Exception:
return r.status_code, {}
def _http_post_json(url: str, headers: Dict[str, str], body: Any, timeout: Tuple[float, float]) -> Tuple[int, Any]:
import requests
r = requests.post(url, headers=headers, json=body, timeout=timeout)
try:
return r.status_code, r.json()
except Exception:
return r.status_code, {}
def _sync_headers(app) -> Optional[Dict[str, str]]:
try:
bu = app.get_backend_url()
tok = app.get_backend_token()
except Exception:
return None
practice_id = ""
try:
practice_id = (app.get_practice_id() or "").strip()
except Exception:
pass
if not bu or not tok or not practice_id:
return None
try:
from basis14 import _get_or_create_device_id
except Exception:
_get_or_create_device_id = lambda: "" # type: ignore
headers = {"X-API-Token": tok, "X-Practice-Id": practice_id}
did = _get_or_create_device_id()
if did:
headers["X-Device-Id"] = did
return headers
def _fetch_server_items(app, item_type: str) -> Optional[List[Dict[str, Any]]]:
headers = _sync_headers(app)
if not headers:
return None
try:
bu = app.get_backend_url().rstrip("/")
except Exception:
return None
url = f"{bu}/v1/sync/items?type={item_type}"
code, data = _http_get_json(url, headers, (3, 12))
if code != 200 or not isinstance(data, dict) or not data.get("ok"):
return None
items = data.get("items")
return items if isinstance(items, list) else []
def _upload_items_bulk(app, items: List[Dict[str, Any]]) -> bool:
if not items:
return True
headers = _sync_headers(app)
if not headers:
return False
try:
bu = app.get_backend_url().rstrip("/")
except Exception:
return False
url = f"{bu}/v1/sync/items/bulk"
code, data = _http_post_json(url, headers, {"items": items}, (3, 20))
return code == 200 and isinstance(data, dict) and data.get("ok")
def _apply_server_items_to_local(app, item_type: str, items: List[Dict[str, Any]]) -> None:
from aza_persistence import (
load_autotext,
load_korrekturen,
load_textbloecke,
save_autotext,
save_korrekturen,
save_textbloecke,
)
if item_type == "textblock":
merged = items_to_local_textblocks(items)
save_textbloecke(merged)
shell = getattr(app, "_aza_office_v1", None)
if shell is not None and hasattr(shell, "refresh_sidebar_textbloecke_section"):
try:
app.after(0, shell.refresh_sidebar_textbloecke_section)
except Exception:
shell.refresh_sidebar_textbloecke_section()
if hasattr(app, "_textbloecke_data"):
app._textbloecke_data = merged
return
if item_type == "correction":
merged = items_to_local_korrekturen(items)
save_korrekturen(merged)
return
if item_type == "autotext":
at = load_autotext()
entries = items_to_local_autotext_entries(items)
if isinstance(at.get("entries"), dict):
at["entries"].clear()
at["entries"].update(entries)
else:
at["entries"] = dict(entries)
save_autotext(at)
tgt = getattr(app, "_autotext_data", None)
if isinstance(tgt, dict):
te = tgt.get("entries")
if isinstance(te, dict):
te.clear()
te.update(entries)
else:
tgt["entries"] = dict(entries)
def _local_items_for_type(item_type: str) -> List[Dict[str, Any]]:
from aza_persistence import load_autotext, load_korrekturen, load_textbloecke
if item_type == "textblock":
return local_textblocks_to_items(load_textbloecke())
if item_type == "correction":
return local_korrekturen_to_items(load_korrekturen())
if item_type == "autotext":
return local_autotext_to_items(load_autotext())
return []
def _local_has_meaningful_data(item_type: str) -> bool:
items = _local_items_for_type(item_type)
if item_type == "textblock":
for it in items:
if (it.get("content") or "").strip() or (
(it.get("title") or "").strip()
and it.get("title") != f"Textblock {it.get('sort_order')}"
):
return True
return False
if item_type == "correction":
return len(items) > 0
if item_type == "autotext":
return len(items) > 0
return False
def sync_practice_items_for_type(app, item_type: str) -> None:
_log_sync("SYNC_ITEMS_START", type=item_type)
server_items = _fetch_server_items(app, item_type)
if server_items is None:
_log_sync("SYNC_ITEMS_FALLBACK_LOCAL", type=item_type, reason="server_unreachable")
return
if server_items:
_backup_local_sync_files()
_apply_server_items_to_local(app, item_type, server_items)
_log_sync("SYNC_ITEMS_OK", type=item_type, count=len(server_items), strategy="server_wins")
return
if _local_has_meaningful_data(item_type):
local_items = _local_items_for_type(item_type)
if _upload_items_bulk(app, local_items):
_log_sync("SYNC_ITEMS_UPLOAD_LOCAL", type=item_type, count=len(local_items))
else:
_log_sync("SYNC_ITEMS_FALLBACK_LOCAL", type=item_type, reason="upload_failed")
return
_log_sync("SYNC_ITEMS_OK", type=item_type, count=0, strategy="empty")
def _sync_worker(app) -> None:
time.sleep(2.0)
try:
from basis14 import _has_remote_backend
except Exception:
def _has_remote_backend() -> bool: # type: ignore
return False
if not _has_remote_backend():
return
if not (app.get_practice_id() or "").strip():
_log_sync("SYNC_ITEMS_FALLBACK_LOCAL", type="all", reason="no_practice_id")
return
for it in SYNC_ITEM_TYPES:
try:
sync_practice_items_for_type(app, it)
except Exception as exc:
_log_sync(
"SYNC_ITEMS_FALLBACK_LOCAL",
type=it,
reason=exc.__class__.__name__,
)
def start_background_practice_items_sync(app) -> None:
try:
threading.Thread(target=_sync_worker, args=(app,), daemon=True).start()
except Exception:
pass
def schedule_practice_items_push(item_type: str) -> None:
key = (item_type or "").strip().lower()
if key not in SYNC_ITEM_TYPES:
return
def push() -> None:
try:
import basis14 as b14
app = None
for w in list(getattr(b14, "_window_registry", []) or []):
if hasattr(w, "get_practice_id"):
app = w
break
if app is None:
return
items = _local_items_for_type(key)
if items and _upload_items_bulk(app, items):
_log_sync("SYNC_ITEMS_PUSH_OK", type=key, count=len(items))
except Exception:
pass
def arm() -> None:
with _push_lock:
old = _push_timers.get(key)
if old is not None:
try:
old.cancel()
except Exception:
pass
t = threading.Timer(
_PUSH_DELAY_SEC,
lambda: threading.Thread(target=push, daemon=True).start(),
)
t.daemon = True
_push_timers[key] = t
t.start()
arm()
def install_persistence_sync_hooks() -> None:
"""Nach lokalem Speichern debounced Upload (ohne Autotext-Engine zu ändern)."""
try:
import aza_persistence as p
if getattr(p, "_aza_practice_sync_hooks", False):
return
orig_tb = p.save_textbloecke
orig_k = p.save_korrekturen
orig_at = p.save_autotext
def save_textbloecke_wrapped(data, _o=orig_tb):
_o(data)
schedule_practice_items_push("textblock")
def save_korrekturen_wrapped(data, _o=orig_k):
_o(data)
schedule_practice_items_push("correction")
def save_autotext_wrapped(data, _o=orig_at):
_o(data)
schedule_practice_items_push("autotext")
p.save_textbloecke = save_textbloecke_wrapped # type: ignore
p.save_korrekturen = save_korrekturen_wrapped # type: ignore
p.save_autotext = save_autotext_wrapped # type: ignore
p._aza_practice_sync_hooks = True
except Exception:
pass
__all__ = [
"ensure_sync_items_schema",
"register_sync_items_routes",
"start_background_practice_items_sync",
"schedule_practice_items_push",
"install_persistence_sync_hooks",
"SYNC_ITEM_TYPES",
]