This commit is contained in:
2026-06-13 22:47:31 +02:00
parent add3da5177
commit d1446fc452
8032 changed files with 2650751 additions and 1551 deletions

View File

@@ -10,6 +10,7 @@ Kein Deploy in diesem Block — Routen werden lokal registriert.
from __future__ import annotations
import json
import re
import sqlite3
import time
from datetime import datetime, timezone
@@ -54,7 +55,187 @@ def ensure_published_doku_prompts_schema(con: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_pub_doku_type_status "
"ON published_doku_prompts(doc_type, status, updated_at)"
)
cols = {r[1] for r in con.execute("PRAGMA table_info(published_doku_prompts)").fetchall()}
if "city" not in cols:
con.execute("ALTER TABLE published_doku_prompts ADD COLUMN city TEXT DEFAULT ''")
con.commit()
ensure_published_doku_prompt_ratings_schema(con)
VALID_HALF_STAR_RATINGS: Tuple[float, ...] = (
0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0,
)
def ensure_published_doku_prompt_ratings_schema(con: sqlite3.Connection) -> None:
con.execute(
"""
CREATE TABLE IF NOT EXISTS published_doku_prompt_ratings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_prompt_id TEXT NOT NULL,
rater_user_id TEXT NOT NULL,
rating_value REAL NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE(public_prompt_id, rater_user_id)
)
"""
)
con.execute(
"CREATE INDEX IF NOT EXISTS idx_pub_doku_ratings_prompt "
"ON published_doku_prompt_ratings(public_prompt_id)"
)
con.commit()
def normalize_rating_value(value: Any) -> Optional[float]:
try:
v = float(value)
except (TypeError, ValueError):
return None
if abs(v * 2 - round(v * 2)) > 1e-9:
return None
v = round(v * 2) / 2.0
if v not in VALID_HALF_STAR_RATINGS:
return None
return float(v)
def _round_rating_average(avg: Optional[float]) -> Optional[float]:
if avg is None:
return None
try:
return round(float(avg) * 10) / 10.0
except (TypeError, ValueError):
return None
def fetch_rating_aggregates_batch(
con: sqlite3.Connection,
public_ids: List[str],
current_user_id: str,
) -> Dict[str, Dict[str, Any]]:
"""Aggregate + my_rating fuer mehrere public_prompt_ids (kein N+1)."""
ids = [str(i).strip() for i in (public_ids or []) if str(i).strip()]
out: Dict[str, Dict[str, Any]] = {
pid: {"rating_average": None, "rating_count": 0, "my_rating": None}
for pid in ids
}
if not ids:
return out
placeholders = ",".join("?" for _ in ids)
rows = con.execute(
f"""
SELECT public_prompt_id, AVG(rating_value) AS avg_v, COUNT(*) AS cnt
FROM published_doku_prompt_ratings
WHERE public_prompt_id IN ({placeholders})
GROUP BY public_prompt_id
""",
ids,
).fetchall()
for pid, avg_v, cnt in rows:
out[str(pid)] = {
"rating_average": _round_rating_average(avg_v),
"rating_count": int(cnt or 0),
"my_rating": None,
}
uid = (current_user_id or "").strip()
if uid:
mine = con.execute(
f"""
SELECT public_prompt_id, rating_value
FROM published_doku_prompt_ratings
WHERE public_prompt_id IN ({placeholders}) AND rater_user_id = ?
""",
ids + [uid],
).fetchall()
for pid, rv in mine:
key = str(pid)
if key in out:
out[key]["my_rating"] = float(rv)
return out
def compute_can_rate(*, author_user_id: str, current_user_id: str, published: bool) -> bool:
if not published:
return False
au = (author_user_id or "").strip()
cu = (current_user_id or "").strip()
if not cu or not au:
return False
return au != cu
def apply_rating_fields_to_public_item(
item: Dict[str, Any],
*,
current_user_id: str,
agg: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
author_uid = str(item.get("author_user_id") or item.get("user_id") or "").strip()
published = str(item.get("status") or "published").strip().lower() == "published"
if not published and "status" not in item:
published = True
base = dict(item)
a = agg or {}
avg = a.get("rating_average", item.get("rating_average"))
cnt = int(a.get("rating_count", item.get("rating_count") or 0) or 0)
my = a.get("my_rating", item.get("my_rating"))
base["rating_average"] = _round_rating_average(avg) if cnt else None
base["rating_count"] = cnt
base["my_rating"] = normalize_rating_value(my) if my is not None else None
base["can_rate"] = compute_can_rate(
author_user_id=author_uid,
current_user_id=current_user_id,
published=published,
)
return base
def upsert_public_prompt_rating(
con: sqlite3.Connection,
*,
public_prompt_id: str,
rater_user_id: str,
rating_value: float,
) -> Dict[str, Any]:
pid = (public_prompt_id or "").strip()
uid = (rater_user_id or "").strip()
rv = normalize_rating_value(rating_value)
if not pid or not uid:
raise ValueError("missing ids")
if rv is None:
raise ValueError("invalid rating")
row = con.execute(
"SELECT user_id, status FROM published_doku_prompts WHERE id = ?",
(pid,),
).fetchone()
if not row:
raise LookupError("not found")
author_uid, status = str(row[0] or ""), str(row[1] or "")
if status != "published":
raise PermissionError("not published")
if author_uid == uid:
raise PermissionError("own template")
now = _now_ts()
con.execute(
"""
INSERT INTO published_doku_prompt_ratings
(public_prompt_id, rater_user_id, rating_value, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(public_prompt_id, rater_user_id) DO UPDATE SET
rating_value = excluded.rating_value,
updated_at = excluded.updated_at
""",
(pid, uid, rv, now, now),
)
con.commit()
agg = fetch_rating_aggregates_batch(con, [pid], uid).get(pid, {})
return apply_rating_fields_to_public_item(
{"id": pid, "author_user_id": author_uid, "status": status},
current_user_id=uid,
agg=agg,
)
def _now_ts() -> int:
@@ -62,8 +243,19 @@ def _now_ts() -> int:
def _sanitize_server_text(text: str) -> str:
from aza_doku_vorlagen import sanitize_prompt_text
return sanitize_prompt_text(text)
"""Plaintext only — serverseitig ohne Desktop/tkinter-Import."""
if not text:
return ""
s = str(text)
s = re.sub(r"(?is)<script[^>]*>.*?</script>", "", s)
s = re.sub(r"(?is)<style[^>]*>.*?</style>", "", s)
s = re.sub(r"<[^>]+>", "", s)
s = re.sub(r"(?i)javascript\s*:", "", s)
s = re.sub(r"(?i)data\s*:", "", s)
s = s.replace("\x00", "")
if len(s) > MAX_PROMPT_CHARS:
s = s[:MAX_PROMPT_CHARS]
return s.strip()
def _resolve_auth_context(con: sqlite3.Connection, request: Request) -> Tuple[str, str]:
@@ -87,6 +279,12 @@ class PublishDokuPromptIn(BaseModel):
server_id: Optional[str] = ""
language: Optional[str] = ""
specialty: Optional[str] = ""
city: Optional[str] = ""
visibility: Optional[str] = "public"
class RateDokuPromptIn(BaseModel):
rating: float
def register_doku_prompt_routes(app, *, stripe_db_path_fn) -> None:
@@ -99,31 +297,72 @@ def register_doku_prompt_routes(app, *, stripe_db_path_fn) -> None:
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)
user_id, _practice_id = _resolve_auth_context(con, request)
rows = con.execute(
"""
SELECT id, doc_type, title, description, content, author_display,
revision, updated_at
SELECT id, user_id, practice_id, doc_type, title, description, content,
author_display, revision, updated_at, specialty, city
FROM published_doku_prompts
WHERE status = 'published'
ORDER BY doc_type, updated_at DESC
"""
).fetchall()
items = [
{
pub_ids = [str(r[0]) for r in rows]
agg_map = fetch_rating_aggregates_batch(con, pub_ids, user_id)
items = []
for r in rows:
raw = normalize_public_doku_item({
"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
]
"user_id": r[1],
"practice_id": r[2],
"doc_type": r[3],
"title": r[4],
"description": r[5] or "",
"content": r[6],
"author_display": r[7] or "",
"revision": int(r[8] or 1),
"updated_at": int(r[9] or 0),
"specialty": r[10] or "",
"city": r[11] or "",
"visibility": "public",
"status": "published",
})
items.append(
apply_rating_fields_to_public_item(
raw,
current_user_id=user_id,
agg=agg_map.get(str(r[0])),
)
)
return {"ok": True, "items": items, "count": len(items)}
@app.post("/v1/doku-prompts/rate/{pub_id}", dependencies=[Depends(require_api_token)])
def doku_prompts_rate(pub_id: str, request: Request, body: RateDokuPromptIn):
db_path = stripe_db_path_fn()
if not Path(db_path).exists():
raise HTTPException(status_code=503, detail="database missing")
rv = normalize_rating_value(body.rating)
if rv is None:
raise HTTPException(status_code=400, detail="invalid rating")
with sqlite3.connect(db_path) as con:
ensure_published_doku_prompts_schema(con)
user_id, _practice_id = _resolve_auth_context(con, request)
try:
result = upsert_public_prompt_rating(
con,
public_prompt_id=pub_id,
rater_user_id=user_id,
rating_value=rv,
)
except LookupError:
raise HTTPException(status_code=404, detail="not found") from None
except PermissionError as exc:
msg = str(exc)
if msg == "own template":
raise HTTPException(status_code=403, detail="cannot rate own template") from None
raise HTTPException(status_code=403, detail="not published") from None
return {"ok": True, **result}
@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()
@@ -149,21 +388,22 @@ def register_doku_prompt_routes(app, *, stripe_db_path_fn) -> None:
"""
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', ?, ?, ?, ?)
author_display, revision, status, language, specialty, city,
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
city=excluded.city, 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,
(body.city or "").strip(), created_at, now,
),
)
con.commit()
@@ -227,7 +467,8 @@ def _http_post(url: str, headers: Dict[str, str], body: Any) -> Tuple[int, Any]:
try:
return r.status_code, r.json()
except Exception:
return r.status_code, {}
raw = (r.text or "").strip()
return r.status_code, {"detail": raw} if raw else {}
def local_private_templates_to_sync_items(data: dict) -> List[Dict[str, Any]]:
@@ -344,24 +585,111 @@ def sync_doku_prompts_with_server(app) -> bool:
return True
def fetch_public_doku_prompts(app) -> List[Dict[str, Any]]:
def normalize_public_doku_item(raw: Dict[str, Any]) -> Dict[str, Any]:
"""Einheitliches Client-Format für öffentliche Doku-Prompts."""
if not isinstance(raw, dict):
return {}
sid = str(raw.get("id") or raw.get("server_id") or "").strip()
author_uid = str(raw.get("author_user_id") or raw.get("user_id") or "").strip()
city = str(raw.get("city") or raw.get("location") or raw.get("region") or "").strip()
return {
"id": sid,
"server_id": sid,
"doc_type": str(raw.get("doc_type") or "").strip(),
"title": str(raw.get("title") or raw.get("name") or "").strip(),
"description": str(raw.get("description") or "").strip(),
"content": str(raw.get("content") or raw.get("prompt") or "").strip(),
"author_display": str(raw.get("author_display") or raw.get("author") or "").strip(),
"author_user_id": author_uid,
"specialty": str(raw.get("specialty") or "").strip(),
"city": city,
"location": city,
"practice_id": str(raw.get("practice_id") or "").strip(),
"visibility": str(raw.get("visibility") or raw.get("scope") or "public").strip(),
"revision": int(raw.get("revision") or 1),
"updated_at": int(raw.get("updated_at") or 0),
"rating_average": raw.get("rating_average"),
"rating_count": int(raw.get("rating_count") or 0),
"my_rating": raw.get("my_rating"),
"can_rate": bool(raw.get("can_rate")),
}
def normalize_public_doku_items(items: List[Any]) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for it in items or []:
norm = normalize_public_doku_item(it if isinstance(it, dict) else {})
if norm.get("id") or norm.get("title"):
out.append(norm)
return out
def fetch_public_doku_prompts_result(app) -> Tuple[List[Dict[str, Any]], Optional[str]]:
"""Lädt öffentliche Vorlagen; Fehler-Tags: __ROUTE_MISSING__, __CONN_ERROR__."""
headers = _sync_headers(app)
if not headers:
return []
return [], None
try:
bu = app.get_backend_url().rstrip("/")
except Exception:
return []
code, data = _http_get(f"{bu}/v1/doku-prompts/public", headers)
return [], "__CONN_ERROR__"
try:
code, data = _http_get(f"{bu}/v1/doku-prompts/public", headers)
except Exception:
return [], "__CONN_ERROR__"
if code in (404, 405):
return [], "__ROUTE_MISSING__"
if code != 200 or not isinstance(data, dict) or not data.get("ok"):
return []
detail = ""
if isinstance(data, dict):
detail = str(data.get("detail") or data.get("message") or "").strip().lower()
if detail in ("not found", "notfound"):
return [], "__ROUTE_MISSING__"
return [], None
items = data.get("items")
return items if isinstance(items, list) else []
if not isinstance(items, list):
return [], None
return normalize_public_doku_items(items), None
def fetch_public_doku_prompts(app) -> List[Dict[str, Any]]:
items, _err = fetch_public_doku_prompts_result(app)
return items
def _profile_publish_extras(app) -> Dict[str, str]:
prof = getattr(app, "_user_profile", {}) or {}
specialty = str(prof.get("specialty") or "").strip()
city = str(prof.get("city") or prof.get("location") or prof.get("region") or "").strip()
return {"specialty": specialty, "city": city}
def resolve_author_display(app) -> str:
"""Verfasser-Anzeigename: bevorzugt Profil-Anzeigename, sonst Signaturname."""
try:
prof = getattr(app, "_user_profile", {}) or {}
for k in ("display_name", "name"):
v = str(prof.get(k) or "").strip()
if v:
return v
except Exception:
pass
try:
from aza_persistence import load_signature_name
return (load_signature_name() or "").strip()
except Exception:
return ""
def publish_doku_prompt(
app, doc_type: str, tpl: dict, title: str, description: str = "",
) -> Tuple[bool, str]:
"""Veröffentlicht eine Vorlage. Rückgabe (ok, message).
Sentinel-Meldungen für saubere UI-Übersetzung (kein rohes "Not Found"):
"__ROUTE_MISSING__" → Route nicht deployt (404)
"__CONN_ERROR__" → Server nicht erreichbar / Timeout
"""
headers = _sync_headers(app)
if not headers:
return False, "Nicht angemeldet oder keine Praxis-Zuordnung."
@@ -369,12 +697,8 @@ def publish_doku_prompt(
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
author = resolve_author_display(app)
extras = _profile_publish_extras(app)
body = {
"doc_type": doc_type,
"title": title,
@@ -382,11 +706,95 @@ def publish_doku_prompt(
"content": tpl.get("content") or "",
"author_display": author,
"server_id": tpl.get("server_id") or tpl.get("id") or "",
"language": "de",
"specialty": (tpl.get("specialty") or extras.get("specialty") or "").strip(),
"city": (tpl.get("city") or tpl.get("location") or extras.get("city") or "").strip(),
"visibility": "public",
}
code, data = _http_post(f"{bu}/v1/doku-prompts/publish", headers, body)
try:
code, data = _http_post(f"{bu}/v1/doku-prompts/publish", headers, body)
except Exception:
return False, "__CONN_ERROR__"
if code == 200 and isinstance(data, dict) and data.get("ok"):
return True, ""
if code in (404, 405):
# Route lokal vorhanden, aber auf dem Live-Server (noch) nicht deployt.
return False, "__ROUTE_MISSING__"
detail = ""
if isinstance(data, dict):
detail = str(data.get("detail") or data.get("message") or "")
if detail.strip().lower() in ("not found", "notfound"):
return False, "__ROUTE_MISSING__"
return False, detail or f"Server-Fehler ({code})"
def unpublish_doku_prompt(app, pub_id: str) -> Tuple[bool, str]:
"""Entfernt eigene Veröffentlichung vom Server (falls Route live)."""
headers = _sync_headers(app)
if not headers:
return False, "Nicht angemeldet oder keine Praxis-Zuordnung."
pid = (pub_id or "").strip()
if not pid:
return False, "Keine Veröffentlichungs-ID."
try:
bu = app.get_backend_url().rstrip("/")
except Exception:
return False, "Backend nicht konfiguriert."
try:
code, data = _http_post(f"{bu}/v1/doku-prompts/unpublish/{pid}", headers, {})
except Exception:
return False, "__CONN_ERROR__"
if code == 200 and isinstance(data, dict) and data.get("ok"):
return True, ""
if code in (404, 405):
return False, "__ROUTE_MISSING__"
detail = ""
if isinstance(data, dict):
detail = str(data.get("detail") or data.get("message") or "")
if detail.strip().lower() in ("not found", "notfound"):
return False, "__ROUTE_MISSING__"
return False, detail or f"Server-Fehler ({code})"
def rate_public_doku_prompt_result(
app, pub_id: str, rating: float,
) -> Tuple[bool, str, Dict[str, Any]]:
"""Bewertet fremde oeffentliche Vorlage. Rueckgabe (ok, message, payload)."""
headers = _sync_headers(app)
if not headers:
return False, "Nicht angemeldet oder keine Praxis-Zuordnung.", {}
pid = (pub_id or "").strip()
if not pid:
return False, "Keine Veröffentlichungs-ID.", {}
rv = normalize_rating_value(rating)
if rv is None:
return False, "Ungültige Bewertung (0,55,0 in 0,5-Schritten).", {}
try:
bu = app.get_backend_url().rstrip("/")
except Exception:
return False, "Backend nicht konfiguriert.", {}
try:
code, data = _http_post(
f"{bu}/v1/doku-prompts/rate/{pid}",
headers,
{"rating": rv},
)
except Exception:
return False, "__CONN_ERROR__", {}
if code == 200 and isinstance(data, dict) and data.get("ok"):
payload = {k: data.get(k) for k in (
"rating_average", "rating_count", "my_rating", "can_rate",
)}
return True, "", payload
if code in (404, 405):
return False, "__ROUTE_MISSING__", {}
if code == 403:
return False, "Diese Vorlage kann nicht bewertet werden.", {}
if code == 400:
return False, "Ungültige Bewertung.", {}
detail = ""
if isinstance(data, dict):
detail = str(data.get("detail") or data.get("message") or "")
if detail.strip().lower() in ("not found", "notfound"):
return False, "__ROUTE_MISSING__", {}
return False, detail or f"Server-Fehler ({code})", {}