# -*- coding: utf-8 -*- """ AZA Kontroll-Hülle — separates read-only Kontrollwerkzeug für Empfang-/Hetzner-Daten. Start: python -u .\\aza_admin_control_shell.py Umgebungsvariablen (optional): AZA_CONTROL_SSH_HOST — Default root@178.104.51.177 AZA_CONTROL_REMOTE_DATA — Remote data-Pfad (/root/aza-app/data) """ from __future__ import annotations import csv import hashlib import html import json import os import queue import re import sqlite3 import subprocess import threading import unicodedata import urllib.error import urllib.parse import urllib.request from collections import defaultdict from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple import shlex import tkinter as tk from tkinter import filedialog, messagebox, scrolledtext, ttk # --- Konstanten / Pfade --- DEFAULT_SSH_SPEC = os.environ.get("AZA_CONTROL_SSH_HOST", "root@178.104.51.177").strip() DEFAULT_REMOTE_DATA = os.environ.get("AZA_CONTROL_REMOTE_DATA", "/root/aza-app/data").strip().rstrip("/") _SNAP_ROOT_WIN = Path(r"C:\Users\surov\Documents\AzA Drive") / "AZA_CONTROL_SNAPSHOTS" REMOTE_CORE_REQUIRED_POST_SCP = [ "empfang_practices.json", "empfang_accounts.json", ] REMOTE_COPY_OPTIONAL_ORDERED = [ "empfang_sessions.json", "empfang_devices.json", "empfang_practice_links.json", "empfang_user_notes.json", "empfang_tasks.json", "stripe_events.log.jsonl", "stripe_webhook.sqlite", "license_status_cache.json", ] STALE_SESSION_SEC = int(os.environ.get("AZA_CONTROL_STALE_SESSION_DAYS", "180")) * 86400 SOLL_PROFIL = { "lindengut": { "practice_id": "prac_883ddc21fb6a", "name_must_contain": "lindengut", "expected_display_names_contains": frozenset( { "andre m surovy", "andre m. surovy", "anja", "empfang laptop", "jelena empfang", "jelena", "test", "zeno", "empfang chat tester", } ), "expect_admin_contains": ("andre", "surovy"), "expect_admin_email": frozenset({"andre.surovy@haut-winterthur.ch".lower()}), }, "obergasse": { "practice_id": "prac_e864d294474e", "name_must_contain": "obergasse", "admin_display_names": frozenset({"birgit"}), "license_customer_email_expect": frozenset({"dermapraxis.meier@hin.ch".lower()}), "roles": {"susanne": "empfang", "birgit": "admin"}, "andre_must_not_admin": True, "andre_display_hints": frozenset({"andre m. surovy", "andre", "andre m surovy"}), }, } # --- Hilfen --- def _now_local_stamp() -> str: return datetime.now().strftime("%Y%m%d_%H%M%S") def _norm_name_key(s: str) -> str: s = unicodedata.normalize("NFKD", (s or "")).strip().lower() for ch in (".", ",", "-", "—", "'", "’"): s = s.replace(ch, " ") return " ".join(s.split()) def _license_suffix(key: Optional[str]) -> str: v = (key or "").strip() if not v: return "" groups = [g for g in re.split(r"[-_\s]", v.upper()) if g] return groups[-1] if groups else v[-12:] def _license_hash_hex(key: Optional[str]) -> str: k = (key or "").strip() if not k: return "" return hashlib.sha256(k.encode("utf-8")).hexdigest() def _mask_license(val: Optional[str]) -> str: v = (val or "").strip() if not v: return "" suf = _license_suffix(v) return f"{_license_hash_hex(v)[:12]}…*{suf}" def _mask_az_keys_in_line(s: str) -> str: pat = re.compile(r"\bAZA-(?:[A-Za-z0-9]{4}-){3}[A-Za-z0-9]{4}\b") def _rep(m: re.Match) -> str: return _mask_license(m.group(0)) return pat.sub(_rep, s) def _short(s: Optional[str], n: int = 14) -> str: t = str(s or "").strip() if len(t) <= n: return t return t[: max(6, n - 1)] + "…" def _sha256_file(path: Path) -> Optional[str]: try: h = hashlib.sha256() with path.open("rb") as bf: for chunk in iter(lambda: bf.read(65536), b""): h.update(chunk) return h.hexdigest() except Exception: return None _IP_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b") def shutil_which(cmd: str) -> Optional[str]: import shutil return shutil.which(cmd) def _first_str(obj: Mapping[str, Any], names: Iterable[str]) -> str: for n in names: v = obj.get(n) if v is None: continue s = str(v).strip() if s: return s[:512] return "" def _parse_ts_maybe(v: Optional[str]) -> Optional[float]: if v is None: return None if isinstance(v, (int, float)): try: return float(v) except Exception: return None s = str(v).strip() if not s: return None try: if s.isdigit() or (s.startswith("-") and s[1:].isdigit()): return float(int(s)) except Exception: pass try: s2 = s.replace("Z", "+00:00") return datetime.fromisoformat(s2).timestamp() except Exception: return None def _ts_iso_or_raw(v: Optional[str]) -> str: if not v: return "" ts = _parse_ts_maybe(v) if ts is None: return str(v)[:64] try: return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC") except Exception: return str(v)[:64] def _open_sqlite_ro(path: Path) -> Optional[sqlite3.Connection]: if not path.is_file(): return None try: uri = path.resolve().as_uri() return sqlite3.connect(f"{uri}?mode=ro", uri=True) except Exception: return None def _sqlite_license_rows(sqlite_path: Path) -> Tuple[List[str], List[sqlite3.Row]]: con = _open_sqlite_ro(sqlite_path) if not con: return [], [] try: con.row_factory = sqlite3.Row cur = con.cursor() try: cur.execute("SELECT * FROM licenses ORDER BY subscription_id COLLATE NOCASE") rows = list(cur.fetchall()) names = rows[0].keys() if rows else [c[1] for c in cur.execute("PRAGMA table_info(licenses)").fetchall()] if rows: names = list(rows[0].keys()) return names, rows except sqlite3.Error: try: cur.execute("SELECT * FROM sqlite_master WHERE type='table'") return [], [] except sqlite3.Error: return [], [] finally: try: con.close() except Exception: pass def _row_to_license_rec(row: sqlite3.Row) -> Dict[str, Any]: d = dict(row) lk = str(d.get("license_key") or "").strip() return { "subscription_id": str(d.get("subscription_id") or "").strip(), "customer_id_stripe": str(d.get("customer_id") or "").strip(), "status": str(d.get("status") or "").strip(), "lookup_key": str(d.get("lookup_key") or "").strip(), "allowed_users": str(d.get("allowed_users") or ""), "devices_per_user": str(d.get("devices_per_user") or ""), "customer_email_license": str(d.get("customer_email") or "").strip(), "client_reference_id": str(d.get("client_reference_id") or "").strip(), "current_period_end_raw": str(d.get("current_period_end") or ""), "updated_at_raw": str(d.get("updated_at") or ""), "license_key_plain": lk, "practice_id": str(d.get("practice_id") or "").strip(), } def _woo_order_from_ref(ref: str) -> str: ref = (ref or "").strip() if not ref: return "" mo = re.search(r"(?:woo|(?:wc))[_-]?order[_-]?(\d+)", ref, re.I) if mo: return mo.group(1) mo2 = re.search(r"\b(?:order[#\s_-]*)(\d{3,})\b", ref, re.I) return mo2.group(1) if mo2 else "" def _period_end_human(val: Optional[str]) -> str: v = val and str(val).strip() if not v: return "" try: secs = int(v) return datetime.fromtimestamp(secs, tz=timezone.utc).strftime("%Y-%m-%d %H:%MZ") except Exception: return v[:32] def _unix_db_ts_display(val: Optional[str]) -> str: """Unix-Sekunden aus SQLite (updated_at o.ä.) als UTC lesbar.""" v = val and str(val).strip() if not v: return "" try: secs = int(float(v)) return datetime.fromtimestamp(secs, tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC") except Exception: return v[:28] def _jsonl_erstes_event_utc(path: Path, needle: str, max_lines: int = 48000) -> str: """Früheste Log-Zeile in stripe_events, die needle enthält (ts oben im JSON). Näherung für 'Checkout/Lizenz im Log'.""" needle = (needle or "").strip() if not needle or not path.is_file(): return "" best: Optional[int] = None n = 0 try: with path.open("r", encoding="utf-8", errors="replace") as fh: for line in fh: n += 1 if n > max_lines: break if needle not in line: continue try: rec = json.loads(line) except Exception: continue ts = rec.get("ts") if ts is None: continue try: ti = int(ts) except (TypeError, ValueError): continue if best is None or ti < best: best = ti except Exception: return "" if best is None: return "" return datetime.fromtimestamp(best, tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC") def _tree_column_heading(col: str) -> str: """Kurze Überschriften; Fallback wie bisher.""" pretty = { "license_key_display": "lizenz anzeige", "stripe_letzte_db_aenderung_utc": "letzte stripe-db änderung", "erstes_passendes_stripe_log": "erstes log-event (abo)", "billing_or_customer_snippet": "rechnung/kunde (stichwort)", } if col in pretty: return pretty[col] return col.replace("_", " ") def _tree_sort_key_tuple(column: str, raw: str) -> Tuple[Any, ...]: v = (raw or "").strip() if column in ("user_count", "admin_count", "idx", "sessions_count_approx"): try: return (0, int(v)) except ValueError: return (1, v.lower()) # alles andere: case-insensitive String return (2, v.lower()) def _stripe_jsonl_billing_hints_by_sub(sqlite_sub_ids: Iterable[str], path: Path, cap_lines: int = 28000) -> Dict[str, str]: by_sub = {sid: [] for sid in sqlite_sub_ids if sid} if not path.is_file() or not by_sub: return {k: "" for k in by_sub} tails = defaultdict(list) try: with path.open("r", encoding="utf-8", errors="replace") as fh: n = 0 for line in fh: n += 1 if n > cap_lines: break low = line.lower() if "billing" not in low and "address" not in low: continue for sid in by_sub.keys(): if sid and sid in line: tails[sid].append(line.strip()[:400]) except Exception: pass out: Dict[str, str] = {} for sid, chunks in tails.items(): seen = [] for c in chunks[:12]: if c not in seen: seen.append(c) out[sid] = (" | ".join(seen))[:1600] return out def _extract_billing_friendly(snippet: str) -> str: if not snippet: return "" out = [] emails = set(re.findall(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", snippet)) for em in sorted(emails)[:6]: out.append(em) m = re.search(r'"city"\s*:\s*"([^"]{2,})"', snippet) if m: out.append(f"stadt:{m.group(1)}") m2 = re.search(r'"line1"\s*:\s*"([^"]{4,})"', snippet) if m2: out.append(f"Zeile1:{m2.group(1)[:80]}") m3 = re.search(r'"postal_code"\s*:\s*"([^"]+)"', snippet) if m3: out.append(f"PLZ:{m3.group(1)}") return "; ".join(out)[:520] def _account_license_supplement_accounts(accounts: Dict[str, dict], seen_plain_keys: set) -> List[Dict[str, Any]]: extra: List[Dict[str, Any]] = [] for uk, ac in accounts.items(): lk = _first_str(ac, ("license_key", "activation_key", "stripe_license_key")) if not lk: continue key_n = lk.strip().upper() if key_n in seen_plain_keys: continue seen_plain_keys.add(key_n) pid = str(ac.get("practice_id") or "").strip() extra.append( { "subscription_id": "", "customer_id_stripe": "", "status": "—", "lookup_key": _first_str(ac, ("lookup_key", "plan_key", "tier")), "allowed_users": "", "devices_per_user": "", "customer_email_license": _first_str(ac, ("stripe_customer_email", "billing_email", "email")), "client_reference_id": "", "current_period_end_raw": "", "updated_at_raw": "", "license_key_plain": lk.strip(), "practice_id": pid, "_source_primary": "empfang_accounts.json", } ) return extra def _merge_license_customer_email_per_practice(license_bundle_rows: List[Dict[str, Any]]) -> Dict[str, str]: best_ts: Dict[str, int] = {} best_mail: Dict[str, str] = {} for r in license_bundle_rows: pid = str(r.get("practice_id") or "").strip() mail = str(r.get("customer_email_license") or "").strip() if not pid or not mail or "@" not in mail: continue try: u = int(float(str(r.get("updated_at_raw") or "0") or "0")) except Exception: u = 0 prev = best_ts.get(pid, -1) if u >= prev: best_ts[pid] = u best_mail[pid] = mail return best_mail def _build_license_display_rows(stripe_sql: Path, stripe_jsonl: Path, practices: Mapping, accounts: Dict[str, dict]) -> List[Dict[str, Any]]: _, sql_rows = _sqlite_license_rows(stripe_sql) recs: List[Dict[str, Any]] = [] plain_seen: set = set() for row in sql_rows: rr = _row_to_license_rec(row) if rr["license_key_plain"]: plain_seen.add(rr["license_key_plain"].strip().upper()) rr["_source_primary"] = "stripe_webhook.sqlite (+ ggf. JSONL)" recs.append(rr) recs.extend(_account_license_supplement_accounts(accounts, plain_seen)) sid_set = list({str(r["subscription_id"]) for r in recs if str(r["subscription_id"])} ) hints = _stripe_jsonl_billing_hints_by_sub(set(sid_set), stripe_jsonl) pname = {pid: str((practices.get(pid) or {}).get("name") or "").strip() for pid in practices if isinstance(practices.get(pid), dict)} out_rows: List[Dict[str, str]] = [] for r in recs: pid = str(r.get("practice_id") or "").strip() lk = str(r.get("license_key_plain") or "") sub = str(r.get("subscription_id") or "") bill_raw = _extract_billing_friendly(hints.get(sub, "")) woo = _woo_order_from_ref(str(r.get("client_reference_id") or "")) u_at = str(r.get("updated_at_raw") or "").strip() first_log = _jsonl_erstes_event_utc(stripe_jsonl, sub) if sub else "" stand_db = _unix_db_ts_display(u_at) row = { "license_key_plain": lk, "license_key_masked": _mask_license(lk), "license_suffix": _license_suffix(lk), "license_sha256": _license_hash_hex(lk), "customer_email_license": str(r.get("customer_email_license") or ""), "practice_id": pid, "practice_name": pname.get(pid, ""), "subscription_id": sub, "woo_order_id": woo, "stripe_customer_id": str(r.get("customer_id_stripe") or ""), "lookup_key": str(r.get("lookup_key") or ""), "status": str(r.get("status") or ""), "stripe_letzte_db_aenderung_utc": stand_db, "erstes_passendes_stripe_log": first_log, "current_period_end": _period_end_human(str(r.get("current_period_end_raw") or "")), "billing_or_customer_snippet": bill_raw, "sources": str(r.get("_source_primary") or "gemischt"), "allowed_users": str(r.get("allowed_users") or ""), "devices_per_user": str(r.get("devices_per_user") or ""), "client_reference_id": str(r.get("client_reference_id") or "")[:120], "updated_at_raw": str(r.get("updated_at_raw") or ""), } out_rows.append(row) return out_rows def _session_device_ip(s: Mapping[str, Any]) -> str: return _first_str( s, ( "ip", "ip_address", "remote_ip", "client_ip", "last_ip", "login_ip", ), ) def _user_agent_str(s: Mapping[str, Any]) -> str: return _first_str(s, ("user_agent", "userAgent", "ua", "browser_ua"))[:400] def _browser_os_guess(ua: str) -> str: if not ua: return "" parts = [] low = ua.lower() if "edg/" in low or "edge" in low: parts.append("Edge") elif "chrome" in low and "edg" not in low: parts.append("Chrome") elif "firefox" in low: parts.append("Firefox") elif "safari" in low and "chrome" not in low: parts.append("Safari") if "windows" in low: parts.append("Windows") elif "mac os" in low or "macintosh" in low: parts.append("macOS") elif "android" in low: parts.append("Android") elif "iphone" in low or "ipad" in low: parts.append("iOS") return " / ".join(parts)[:120] def _device_platform_str(d: Mapping[str, Any]) -> str: parts = [ _first_str(d, ("device_name", "name", "label")), _first_str(d, ("browser", "browser_name")), _first_str(d, ("os", "os_name", "platform")), ] ua = _user_agent_str(d) if ua and not any(parts): parts.append(_browser_os_guess(ua)) return " ".join(p for p in parts if p).strip()[:200] def _count_json_notes(path: Path) -> str: if not path.is_file(): return "fehlt im Snapshot" d = _load_json(path, None) if d is None or isinstance(d, dict) and d.get("__error__"): return "—" if isinstance(d, dict): for k in ("notes", "items", "data", "list"): v = d.get(k) if isinstance(v, (list, dict)): return str(len(v)) return str(len(d)) if isinstance(d, list): return str(len(d)) return "? " + type(d).__name__ def _count_generic_json(path: Path) -> str: if not path.is_file(): return "fehlt im Snapshot" d = _load_json(path, None) if d is None: return "— fehlt" if isinstance(d, dict): err = d.get("__error__") if isinstance(err, str): return f"(Fehler: {err[:80]})" return f"Dict mit {len(d)} Schlüsseln (Inhalte nicht ausgewertet)" if isinstance(d, list): return str(len(d)) + " Einträge (Inhalte nicht ausgewertet)" return "Unbekanntes Format" def _stripe_jsonl_summary(path: Path, limit_lines: int = 8000) -> Dict[str, Any]: out: Dict[str, Any] = {"lines_seen": 0, "stripe_like_lines": 0, "emails_seen": [], "skipped_tail": False} if not path.is_file(): out["notes"] = "Datei nicht im Snapshot vorhanden" return out email_pat = re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}") emails_found: List[str] = [] stripe_kw = ("subscription", "invoice", "customer", "checkout", "license", "stripe") lines_seen = 0 with path.open("r", encoding="utf-8", errors="replace") as f: for line in f: lines_seen += 1 if lines_seen > limit_lines: out["skipped_tail"] = True break low = line.lower() if any(k in low for k in stripe_kw): out["stripe_like_lines"] += 1 for em in email_pat.findall(line)[:15]: if em.lower() not in [x.lower() for x in emails_found]: emails_found.append(em) if len(emails_found) >= 40: break out["lines_seen"] = lines_seen out["emails_seen"] = emails_found[:30] return out def _sqlite_summary(sqlite_path: Path) -> str: con = _open_sqlite_ro(sqlite_path) if not con: return "nicht lesbar oder fehlend" try: cur = con.cursor() cur.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") tables = [r[0] for r in cur.fetchall()] return f"{len(tables)} Tabellen: " + ", ".join(tables[:12]) + (" …" if len(tables) > 12 else "") except Exception as exc: return f"Fehler: {exc}" finally: try: con.close() except Exception: pass def _load_json(path: Path, default): try: if path.is_file(): return json.loads(path.read_text(encoding="utf-8")) except Exception as exc: return {"__error__": str(exc)} return default def lookup_ip_geo_optional(ip: str, timeout: int = 14) -> str: """Optional externe Abfrage (nur auf Knopfdruck). Fehler -> leerer String.""" ip = (ip or "").strip() if not ip or not _IP_RE.fullmatch(ip): return "" q = urllib.parse.urlencode({"fields": "status,message,country,regionName,city,isp"}) url = f"http://ip-api.com/json/{ip}?{q}" try: req = urllib.request.Request(url, headers={"User-Agent": "AZA-Kontroll-Huelle/1 (read-only admin)"}) with urllib.request.urlopen(req, timeout=timeout) as resp: data = json.loads(resp.read().decode("utf-8", errors="replace")) if data.get("status") != "success": return f"API: {data.get('message') or 'fail'}" bits = [data.get("city"), data.get("regionName"), data.get("country"), data.get("isp")] return ", ".join(str(b) for b in bits if b)[:240] except Exception as exc: return f"Fehler: {type(exc).__name__}" def analyse_snapshot( raw_copy_dir: Path, scp_optional_misses: Optional[List[str]] = None, ) -> Tuple[Dict[str, Any], List[str]]: warnings: List[str] = [] if scp_optional_misses: warnings.extend(scp_optional_misses) paths = { "practices": raw_copy_dir / "empfang_practices.json", "accounts": raw_copy_dir / "empfang_accounts.json", "sessions": raw_copy_dir / "empfang_sessions.json", "devices": raw_copy_dir / "empfang_devices.json", "links": raw_copy_dir / "empfang_practice_links.json", "notes": raw_copy_dir / "empfang_user_notes.json", "tasks": raw_copy_dir / "empfang_tasks.json", "stripe_log": raw_copy_dir / "stripe_events.log.jsonl", "stripe_sql": raw_copy_dir / "stripe_webhook.sqlite", } for label, pth in paths.items(): if label in ("practices", "accounts"): continue if not pth.is_file(): warnings.append(f"Optionale Datei nicht vorhanden: {pth.name}") practices_raw = _load_json(paths["practices"], {}) accounts_raw = _load_json(paths["accounts"], {}) sessions_raw = _load_json(paths["sessions"], {}) devices_raw = _load_json(paths["devices"], {}) links_raw = _load_json(paths["links"], {"links": []}) if isinstance(practices_raw, dict) and practices_raw.get("__error__"): warnings.append(f"empfang_practices.json: {practices_raw.get('__error__')}") practices: Dict[str, Any] = {} else: practices = practices_raw if isinstance(practices_raw, dict) else {} if isinstance(accounts_raw, dict) and accounts_raw.get("__error__"): warnings.append(f"empfang_accounts.json: {accounts_raw.get('__error__')}") accounts: Dict[str, dict] = {} else: accounts = {k: v for k, v in (accounts_raw or {}).items() if isinstance(v, dict)} if isinstance(sessions_raw, dict) and sessions_raw.get("__error__"): warnings.append(f"empfang_sessions.json: {sessions_raw.get('__error__')}") sessions_dict: Dict[str, dict] = {} else: sessions_dict = sessions_raw if isinstance(sessions_raw, dict) else {} if isinstance(devices_raw, dict) and devices_raw.get("__error__"): warnings.append(f"empfang_devices.json: {devices_raw.get('__error__')}") devices_dict: Dict[str, dict] = {} else: devices_dict = devices_raw if isinstance(devices_raw, dict) else {} if isinstance(links_raw, dict) and links_raw.get("__error__"): warnings.append(f"empfang_practice_links.json: {links_raw.get('__error__')}") links_list: List[Any] = [] elif isinstance(links_raw, dict): ll = links_raw.get("links") links_list = ll if isinstance(ll, list) else [] if not isinstance(ll, list): warnings.append("empfang_practice_links.json: Schlüssel »links« ist keine Liste") else: links_list = [] warnings.append("empfang_practice_links.json: nicht als Objekt lesbar") by_practice: Dict[str, List[dict]] = defaultdict(list) for uid_k, a in accounts.items(): pid = str(a.get("practice_id") or "").strip() if pid: by_practice[pid].append(a) license_rows_ui = _build_license_display_rows(paths["stripe_sql"], paths["stripe_log"], practices, accounts) lic_customer_by_pid = _merge_license_customer_email_per_practice(license_rows_ui) dup_names = defaultdict(list) for pid, pdata in practices.items(): nm = _norm_name_key(pdata.get("name") or "") if isinstance(pdata, dict) else "" if nm: dup_names[nm].append(pid) practice_rows = [] for pid in sorted(set(practices.keys()) | set(by_practice.keys())): pdata = practices.get(pid) if isinstance(practices.get(pid), dict) else {} name = str(pdata.get("name") or "").strip() if pdata else "(kein Stammsatz in practices.json)" invite = str(pdata.get("invite_code") or "").strip() practice_meta_email = _first_str(pdata, ("admin_email", "contact_email", "meta_email")) users = sorted(by_practice.get(pid, []), key=lambda x: str(x.get("display_name") or "").lower()) admin_users = [u for u in users if str(u.get("role") or "").lower() == "admin"] admin_names_join = "; ".join(sorted((str(a.get("display_name") or "?") for a in admin_users), key=str.lower)) admin_account_emails = "; ".join( sorted({str(a.get("email") or "").strip() for a in admin_users if str(a.get("email") or "").strip()}, key=str.lower) ) lic_cust_mail = lic_customer_by_pid.get(pid, "").strip() hn: List[str] = [] nm_key = _norm_name_key(name) if nm_key: collide = dup_names.get(nm_key) or [] if len(collide) > 1: hn.append("Namensgleich mehrerer IDs (Praxisname)") wmsg = "Gleicher Praxisname mehrfacher Datensatz (normalisiert): " + "; ".join(sorted(collide)) if wmsg not in warnings: warnings.append(wmsg) if not admin_users: hn.append("keine Admin-Konten") warnings.append(f"Praxis '{name}' [{pid}] hat keine role=admin-Konten laut Kontenliste.") if len(admin_users) > 1: hn.append("mehrere Admins") adm_mail_set_lower = { str(a.get("email") or "").strip().lower() for a in admin_users if str(a.get("email") or "").strip() } if lic_cust_mail and adm_mail_set_lower and lic_cust_mail.lower() not in adm_mail_set_lower: hn.append("Admin-E-Mail weicht von Lizenz-/Kunden-E-Mail ab") warnings.append(f"Abweichende E-Mail: Praxis {_short(pid)} — Admin-Konto vs. Stripe-Kunden-Mail") hint = "; ".join(hn) if hn else "OK" practice_rows.append( { "practice_name": name, "practice_id": pid, "invite_code": invite, "user_count": str(len(users)), "admin_count": str(len(admin_users)), "admin_names": admin_names_join or "—", "admin_account_emails": admin_account_emails or "—", "practice_meta_email": practice_meta_email or "—", "license_customer_email": lic_cust_mail or "—", "hints": hint, } ) users_rows = [] for uid, ac in sorted(accounts.items(), key=lambda x: str(x[1].get("display_name") or "").lower()): pid = str(ac.get("practice_id") or "").strip() pname = "" pdata = practices.get(pid) if isinstance(practices.get(pid), dict) else {} if pdata: pname = str(pdata.get("name") or "").strip() role = str(ac.get("role") or "").strip() admin_src = "account.role" if role.lower() == "admin" else "—" lic_mail_pr = lic_customer_by_pid.get(pid, "").strip() lk_plain = ac.get("license_key") or ac.get("activation_key") users_rows.append( { "practice_name": pname or "(unbekannt)", "practice_id": pid, "user_id_short": _short(ac.get("user_id") or uid, 16), "user_id_full": str(ac.get("user_id") or uid or "").strip(), "display_name": str(ac.get("display_name") or "").strip(), "login_name": str(ac.get("login_name") or "").strip(), "email": str(ac.get("email") or "").strip(), "practice_license_customer_email": lic_mail_pr or "—", "role": role, "admin_source": admin_src, "status": str(ac.get("status") or "active").strip(), "updated": str(ac.get("updated") or ac.get("created") or "").strip(), "license_key_plain": str(lk_plain or "").strip(), "license_key_masked": _mask_license(str(lk_plain or "").strip()), } ) if not str(ac.get("email") or "").strip(): warnings.append(f"Leere E-Mail bei Konto {ac.get('display_name')!r} ({_short(uid, 10)})") practice_ids_set = set(practices.keys()) | set(by_practice.keys()) practice_name_by_id = { k: str((v or {}).get("name") or "").strip() for k, v in practices.items() if isinstance(v, dict) } session_rows = [] acc_by_uid = {str(a.get("user_id") or "").strip(): a for a in accounts.values()} OB_ID = "prac_e864d294474e" for idx, (tok, s) in enumerate(sorted(sessions_dict.items(), key=lambda x: str(x[0])[:12])): if not isinstance(s, dict): continue pid = str(s.get("practice_id") or "").strip() uid = str(s.get("user_id") or "").strip() s_role = str(s.get("role") or "").strip() acc = acc_by_uid.get(uid) or {} acc_role = str(acc.get("role") or "").strip() ua = _user_agent_str(s) dip = _session_device_ip(s) mism_parts: List[str] = [] if not uid.strip(): warnings.append(f"Session #{idx + 1} ohne user_id ({_session_device_ip(s) or tok[:8]}…)") if acc and s_role and acc_role and s_role.lower() != acc_role.lower(): mism_parts.append(f"Session ≠ Konto Rolle ({s_role!r}/{acc_role!r})") warnings.append(f"Session #{idx + 1} user {_short(uid)}: Session-Rolle {s_role!r} ≠ Kontorolle {acc_role!r}") acc_pid = str(acc.get("practice_id") or "").strip() if acc and pid and acc_pid and acc_pid != pid: mism_parts.append("Session-practice≠Konto-practice") warnings.append(f"Session #{idx + 1} Session-Praxis {pid} ≠ Konto-praxis {acc_pid}") mismatch_display = "; ".join(mism_parts) dsp = str(s.get("display_name") or acc.get("display_name") or "").strip() if pid == OB_ID and "birgit" in _norm_name_key(dsp): if s_role.lower() == "arzt" and acc_role.lower() == "admin": bw = ( "Obergasse Birgit-Session zeigt weiter Rolle=arzt bei Kontoadmin — bitte Kontext prüfen" ) warnings.append(bw) fst = ( _first_str(s, ("first_seen", "created_at", "created")) or _first_str(acc, ("created_at",)) ) last_raw = ( _first_str(s, ("last_seen", "updated_at", "updated", "ts", "heartbeat")) or _first_str(acc, ("updated",)) ) ts_last = _parse_ts_maybe(last_raw) if ts_last: ago = datetime.now(timezone.utc).timestamp() - ts_last if ago > STALE_SESSION_SEC: warnings.append(f"Sehr alte Session (>{STALE_SESSION_SEC // 86400}d idle): {_short(uid)} @{pid}") session_rows.append( { "idx": str(idx + 1), "practice_name": practice_name_by_id.get(pid, ""), "practice_id": pid, "user_id_short": _short(uid, 22), "user_id_full": uid, "display_name": dsp, "session_role": s_role or "—", "account_role": acc_role or "—", "device_id_short": _short(_first_str(s, ("device_id", "hardware_id")), 14), "ip_address": dip or "", "ort_nach_ip": "nicht aufgelöst", "first_seen": _ts_iso_or_raw(fst), "last_seen": _ts_iso_or_raw(last_raw), "user_agent": ua[:260], "browser_os": _browser_os_guess(ua), "mismatch": mismatch_display or "—", "_source_row": "empfang_sessions.json", "_ip_key": dip, } ) if pid and pid not in practice_ids_set: warnings.append(f"Session verweist auf unbekannte practice_id: {pid}") device_rows = [] for dk, d in sorted(devices_dict.items(), key=lambda x: str(x[0])): if not isinstance(d, dict): continue pid = str(d.get("practice_id") or "").strip() uid = str(d.get("user_id") or "").strip() ua_d = _user_agent_str(d) dip_d = _session_device_ip(d) fst = _first_str(d, ("created_at", "first_seen", "registered_at")) last_active = _first_str(d, ("last_active", "last_seen", "updated_at")) dsp_dev = "" if uid: acc_d = acc_by_uid.get(uid) or {} dsp_dev = str(acc_d.get("display_name") or "").strip() device_rows.append( { "display_name": dsp_dev or "—", "device_id_short": _short(dk, 18), "device_id_full": str(dk), "practice_id": pid, "practice_name": practice_name_by_id.get(pid, ""), "user_id_short": _short(uid, 18), "user_id_full": uid, "device_name_browser_os": (_device_platform_str(d) + (" | " + _browser_os_guess(ua_d) if ua_d else ""))[ :260 ], "user_agent": ua_d[:260], "ip_address": dip_d, "ort_nach_ip": "nicht aufgelöst", "first_seen": _ts_iso_or_raw(fst), "last_seen": _ts_iso_or_raw(last_active), "trust": str(d.get("trust_status") or "").strip(), "_source_row": "empfang_devices.json", "_ip_key": dip_d, } ) if not uid.strip(): warnings.append(f"Gerät {_short(str(dk), 12)} ohne user_id (@{pid or '?'})") norm_dev_id = defaultdict(list) for r in device_rows: dk = str(r.get("device_id_full") or "").strip() if dk: norm_dev_id[dk].append(r.get("practice_id") or "") for dk_full, plist in norm_dev_id.items(): uniq = sorted({p for p in plist if p}) if len(uniq) > 1: warnings.append(f"gleiche device_id in mehreren Praxen: {_short(dk_full,14)} → {uniq}") ip_to_practices: Dict[str, set] = defaultdict(set) ip_to_sess = defaultdict(int) for r in session_rows: ipa = str(r.get("ip_address") or "").strip() if ipa and _IP_RE.fullmatch(ipa): pid = str(r.get("practice_id") or "").strip() ip_to_sess[ipa] += 1 if pid: ip_to_practices[ipa].add(pid) for r in device_rows: ipa = str(r.get("ip_address") or "").strip() if ipa and _IP_RE.fullmatch(ipa): pid = str(r.get("practice_id") or "").strip() if pid: ip_to_practices[ipa].add(pid) for ipa, plist in ip_to_practices.items(): if len(plist) > 1: warnings.append(f"gleiche IP in mehreren Praxen ({ipa}): {sorted(plist)}") unknown_practice_dev = [r for r in device_rows if r.get("practice_id") and str(r["practice_id"]) not in practice_ids_set] for r in unknown_practice_dev[:50]: warnings.append(f"Gerät mit unbekannter practice_id: {r.get('practice_id')} / dev {_short(r.get('device_id_full'),14)}") link_rows: List[Dict[str, str]] = [] for L in links_list or []: if not isinstance(L, dict): continue st = str(L.get("status") or "").strip() ct = str(L.get("contact_type") or "").strip() sp = str(L.get("source_practice_id") or "").strip() tp = str(L.get("target_practice_id") or "").strip() inv = str(L.get("invite_code_used") or L.get("invite_code") or "").strip() lid = str(L.get("id") or "").strip() cr = _ts_iso_or_raw(L.get("created_at") or L.get("updated_at") or "") link_rows.append( { "status": st, "contact_type": ct, "source_practice": practice_name_by_id.get(sp, sp), "target_practice": practice_name_by_id.get(tp, tp), "source_id": _short(sp, 18), "target_id": _short(tp, 18), "invite_used": inv[:40], "direction": str(L.get("direction") or ""), "created_at": cr[:32], "link_id_short": _short(lid, 14), } ) if not link_rows: p_links = paths["links"] if not p_links.is_file(): hint = "Hinweis: empfang_practice_links.json fehlt im Snapshot (optional)." elif isinstance(links_raw, dict) and links_raw.get("__error__"): hint = "Hinweis: practice_links konnte nicht geparst werden (siehe Warnliste)." else: hint = "Hinweis: Datei vorhanden, aber »links« ist leer [] (noch keine Praxis-Verbindungen)." link_rows.append( { "status": "—", "contact_type": "—", "source_practice": hint, "target_practice": "", "source_id": "", "target_id": "", "invite_used": "", "direction": "", "created_at": "", "link_id_short": "", } ) email_to_practices: Dict[str, set] = defaultdict(set) login_to_practices: Dict[str, set] = defaultdict(set) for ac in accounts.values(): pid = str(ac.get("practice_id") or "").strip() em = str(ac.get("email") or "").strip().lower() ln = str(ac.get("login_name") or "").strip().lower() if em: email_to_practices[em].add(pid) if ln: login_to_practices[ln].add(pid) for em, pids in email_to_practices.items(): if len(pids) > 1: warnings.append(f"E-Mail mehrfacher Praxis-Zuordnung: {em} -> Praxen {sorted(pids)}") for ln, pids in login_to_practices.items(): if len(pids) > 1 and ln: warnings.append(f"gleicher login_name mehrerer Praxen: {ln!r} -> {sorted(pids)}") soll_messages: List[Dict[str, str]] = [] def _practice_row_by_id(target_pid: str) -> Optional[dict]: for rr in practice_rows: if rr.get("practice_id") == target_pid: return rr return None lg = SOLL_PROFIL["lindengut"] row_lg = _practice_row_by_id(lg["practice_id"]) if not row_lg: soll_messages.append({"level": "red", "text": f"Lindengut-Soll: ID {lg['practice_id']} fehlt"}) else: nm = row_lg.get("practice_name", "") if lg["name_must_contain"].lower() not in nm.lower(): soll_messages.append({"level": "yellow", "text": f"Lindengut-Soll: Name erwartet ‚Praxis Lindengut‘-Kontext — ist ‚{nm}‘"}) else: soll_messages.append({"level": "green", "text": "Lindengut-Soll: Name plausibel."}) admins_ok = [] for ac in by_practice.get(lg["practice_id"], []): if str(ac.get("role")).lower() != "admin": continue nm_a = _norm_name_key(ac.get("display_name") or "") if all(h in nm_a for h in lg["expect_admin_contains"]): admins_ok.append(ac) em_set = lg["expect_admin_email"] if not admins_ok: soll_messages.append({"level": "yellow", "text": "Lindengut-Soll: kein André M. Surovy als Admin erkannt"}) else: good_em = False for aa in admins_ok: mail = str(aa.get("email") or "").strip().lower() if mail and mail in em_set: good_em = True soll_messages.append( { "level": "green" if good_em else "yellow", "text": ( "Lindengut-Soll: André-Admin gefunden mit erwarteter E-Mail." if good_em else "Lindengut-Soll: André-Admin nicht mit andre.surovy@haut-winterthur.ch verknüpft" ), } ) dnames_by_norm = defaultdict(list) for ac in by_practice.get(lg["practice_id"], []): dnames_by_norm[_norm_name_key(ac.get("display_name") or "")].append(ac) missed = [] for exp in lg["expected_display_names_contains"]: if not any(exp in k or k.startswith(exp[:5]) for k in dnames_by_norm.keys()): missed.append(exp) if missed: soll_messages.append( {"level": "yellow", "text": f"Lindengut-Soll: fehlende erwartete Anzeigenamen-Stichprobe: {', '.join(missed)}",} ) else: soll_messages.append({"level": "green", "text": "Lindengut-Soll: Kernbenutzer-Stichprobe weitgehend ok."}) ob = SOLL_PROFIL["obergasse"] row_ob = _practice_row_by_id(ob["practice_id"]) lic_ob = lic_customer_by_pid.get(ob["practice_id"], "").strip().lower() lexp = next(iter(ob["license_customer_email_expect"])) if ob["license_customer_email_expect"] else "" if not row_ob: soll_messages.append({"level": "red", "text": f"Obergasse-Soll: ID {ob['practice_id']} fehlt"}) else: nm = row_ob.get("practice_name", "") if ob["name_must_contain"].lower() not in nm.lower(): soll_messages.append({"level": "yellow", "text": f"Obergasse-Soll: Name erwartet Obergasse-Kontext — ist ‚{nm}‘"}) else: soll_messages.append({"level": "green", "text": "Obergasse-Soll: Name plausibel."}) if lexp: if not lic_ob: soll_messages.append( {"level": "yellow", "text": "Obergasse-Soll: keine Lizenz-/Kunden-E-Mail aus Stripe-Tabelle ermittelbar",} ) elif lexp != lic_ob: soll_messages.append( { "level": "yellow", "text": f"Obergasse-Soll: Lizenz-Mail ist ‚{lic_ob}‘, erwartet ‚dermapraxis.meier@hin.ch‘", }, ) else: soll_messages.append({"level": "green", "text": "Obergasse-Soll: Stripe-Kundenmail wie erwartet."}) members = {_norm_name_key(a.get("display_name") or ""): a for a in by_practice.get(ob["practice_id"], [])} ok_b_admin = False for dk, ac in members.items(): if "birgit" not in dk: continue if str(ac.get("role") or "").lower() == "admin": ok_b_admin = True if not ok_b_admin: soll_messages.append({"level": "red", "text": "Obergasse-Soll: keine Birgit-Admin gefunden"}) else: soll_messages.append({"level": "green", "text": "Obergasse-Soll: Birgit als Admin gefunden"}) suc = False for dk, ac in members.items(): if "susanne" in dk and str(ac.get("role") or "").lower() == "empfang": suc = True soll_messages.append( {"level": "green" if suc else "yellow", "text": "Obergasse-Soll: Susanne empfang" if suc else "Obergasse-Soll: Susanne nicht als empfang",} ) andre_problem = [] if ob.get("andre_must_not_admin"): for dk, ac in members.items(): if not any(h in dk for h in ob.get("andre_display_hints", ())): continue if str(ac.get("role") or "").lower() == "admin": andre_problem.append(str(ac.get("display_name"))) if andre_problem: soll_messages.append( {"level": "yellow", "text": "Obergasse-Soll: André als admin gelistet: " + ", ".join(andre_problem),} ) else: soll_messages.append({"level": "green", "text": "Obergasse-Soll: kein André-Admin in dieser Praxis."}) ip_summary_rows = [] for ipa in sorted(ip_to_practices.keys()): ip_summary_rows.append( { "ip": ipa, "ort_nach_ip": "nicht aufgelöst", "practices": "; ".join(sorted(ip_to_practices[ipa])), "sessions_count_approx": str(ip_to_sess.get(ipa, 0)), } ) stats_block = { "notes_summary": _count_json_notes(paths["notes"]), "tasks_summary": _count_generic_json(paths["tasks"]), "stripe_events": _stripe_jsonl_summary(paths["stripe_log"]), "stripe_sqlite": _sqlite_summary(paths["stripe_sql"]) if paths["stripe_sql"].exists() else "(nicht kopiert oder fehlt)", } result = { "practice_rows": practice_rows, "users_rows": users_rows, "session_rows": session_rows, "device_rows": device_rows, "link_rows": link_rows, "license_rows": license_rows_ui, "ip_summary_rows": ip_summary_rows, "warnings": sorted(set(warnings)), "soll_messages": soll_messages, "stats_block": stats_block, "_meta": {"raw_copy_dir": str(raw_copy_dir.resolve())}, } return result, sorted(set(warnings)) def _bundle_for_inventory_json(bundle: Mapping[str, Any]) -> Dict[str, Any]: out = {} skip_root = {"_meta", "_ip_geo_cache_live"} for k, v in bundle.items(): if k in skip_root: continue if k == "license_rows": slim = [] for r in v or []: if isinstance(r, dict): rr = dict(r) kk = rr.get("license_key_plain") rr.pop("license_key_plain", None) rr["license_key_masked"] = _mask_license(str(kk or "")) slim.append(rr) out[k] = slim continue if k == "users_rows": slim_u = [] for r in v or []: if isinstance(r, dict): rr = dict(r) lk = rr.get("license_key_plain") rr.pop("license_key_plain", None) if lk: rr["license_key_masked"] = _mask_license(str(lk)) slim_u.append(rr) out[k] = slim_u continue out[k] = v return out def _warnings_for_txt(bundle: Mapping[str, Any]) -> List[str]: lines = [] for w in bundle.get("warnings", []) or []: lines.append(_mask_az_keys_in_line(str(w))) for sm in bundle.get("soll_messages", []) or []: lines.append(_mask_az_keys_in_line(str(sm.get("text", "")))) return lines def export_snapshot_folder(snap_dir: Path, bundle: Mapping[str, Any], server_spec: str, remote_path: str) -> Tuple[Path, Path, Path]: snap_dir.mkdir(parents=True, exist_ok=True) rd = snap_dir / "raw_data_copy" if not rd.is_dir(): raise FileNotFoundError(f"Kein Ordner raw_data_copy unter {snap_dir}") readme_lines = [ "AZA Kontroll-Hülle Snapshot", f"Zeit UTC: {datetime.now(timezone.utc).isoformat(timespec='seconds')}Z", f"Konfig read-only Kopie/Hinweis: server_label={server_spec} remote_dir={remote_path}", "", "Hinweis: Keine Patienten-/Chat-/Notiz-/Aufgabeninhalte. Lizenzschlüssel in README/warnings/HTML nur maskiert.", "", "SHA256 kopierter Rohdateien (raw_data_copy):", "", ] for p in sorted(rd.iterdir()): if p.is_file(): hs = _sha256_file(p) if hs: readme_lines.append(f" {p.name} SHA256:{hs}") inv_path = snap_dir / "practice_inventory.json" csv_pr = snap_dir / "practice_inventory.csv" usr_csv = snap_dir / "users.csv" adm_csv = snap_dir / "admins.csv" lic_csv = snap_dir / "licenses.csv" sess_csv = snap_dir / "sessions.csv" dev_csv = snap_dir / "devices.csv" ip_csv = snap_dir / "ip_summary.csv" warn_path = snap_dir / "warnings.txt" html_path = snap_dir / "report.html" inv_path.write_text(json.dumps(_bundle_for_inventory_json(bundle), indent=2, ensure_ascii=False), encoding="utf-8") pcols = [ "practice_name", "practice_id", "invite_code", "user_count", "admin_count", "admin_names", "admin_account_emails", "practice_meta_email", "license_customer_email", "hints", ] def _csv_write(path_: Path, header: Sequence[str], rows_: Sequence[Mapping[str, Any]]) -> None: with path_.open("w", newline="", encoding="utf-8-sig") as fh: w = csv.writer(fh, delimiter=";") w.writerow(list(header)) for rr in rows_: w.writerow([(str(rr.get(c, "") if isinstance(rr, dict) else "") or "").replace("\r\n", " ")[:8000] for c in header]) _csv_write(csv_pr, pcols, bundle.get("practice_rows", []) or []) ucols = [ "practice_name", "practice_id", "user_id_short", "user_id_full", "display_name", "login_name", "email", "practice_license_customer_email", "role", "admin_source", "status", "updated", "license_key_masked", ] adm_rows_export: List[Dict[str, str]] = [] with usr_csv.open("w", newline="", encoding="utf-8-sig") as fh_u: wu = csv.writer(fh_u, delimiter=";") wu.writerow(ucols) for rr in bundle.get("users_rows", []) or []: if not isinstance(rr, dict): continue rw = dict(rr) lk = rw.get("license_key_plain") rw.pop("license_key_plain", None) rw["license_key_masked"] = _mask_license(str(lk or "").strip()) if str(rw.get("role") or "").lower() == "admin": adm_rows_export.append(rw) wu.writerow([str(rw.get(c, "") or "") for c in ucols]) acols = [ "practice_name", "practice_id", "user_id_short", "display_name", "login_name", "email", "practice_license_customer_email", "role", "status", "license_key_masked", ] with adm_csv.open("w", newline="", encoding="utf-8-sig") as fh_a: wa = csv.writer(fh_a, delimiter=";") wa.writerow(acols) for rw in adm_rows_export: wa.writerow([str(rw.get(c, "") or "") for c in acols]) lcols = [ "license_key_masked", "license_suffix", "license_sha256", "customer_email_license", "practice_name", "practice_id", "subscription_id", "stripe_customer_id", "woo_order_id", "lookup_key", "status", "current_period_end", "stripe_letzte_db_aenderung_utc", "erstes_passendes_stripe_log", "allowed_users", "devices_per_user", "billing_or_customer_snippet", "sources", "client_reference_id", ] with lic_csv.open("w", newline="", encoding="utf-8-sig") as fh_l: wl = csv.writer(fh_l, delimiter=";") wl.writerow(lcols) for rw in bundle.get("license_rows", []) or []: if not isinstance(rw, dict): continue lk = rw.get("license_key_plain") or "" rr = dict(rw) rr.pop("license_key_plain", None) rr["license_key_masked"] = _mask_license(str(lk).strip()) wl.writerow([str(rr.get(c, "") or "") for c in lcols]) sess_cols_export = [ "idx", "practice_name", "practice_id", "user_id_short", "display_name", "session_role", "account_role", "ip_address", "ort_nach_ip", "first_seen", "last_seen", "device_id_short", "browser_os", "mismatch", "user_agent", "source", ] with sess_csv.open("w", newline="", encoding="utf-8-sig") as fh_s: ws = csv.writer(fh_s, delimiter=";") ws.writerow(sess_cols_export) for r in bundle.get("session_rows", []) or []: if not isinstance(r, dict): continue rr = {k: v for k, v in r.items() if not str(k).startswith("_")} rr.setdefault("source", "empfang_sessions.json") ws.writerow([str(rr.get(c, "") or "") for c in sess_cols_export]) dev_cols_export = [ "practice_name", "practice_id", "display_name", "user_id_short", "device_name_browser_os", "device_id_short", "ip_address", "ort_nach_ip", "first_seen", "last_seen", "trust", "user_agent", "source", ] with dev_csv.open("w", newline="", encoding="utf-8-sig") as fh_d: wd = csv.writer(fh_d, delimiter=";") wd.writerow(dev_cols_export) for r in bundle.get("device_rows", []) or []: if not isinstance(r, dict): continue rr = {k: v for k, v in r.items() if not str(k).startswith("_")} rr.setdefault("source", "empfang_devices.json") wd.writerow([str(rr.get(c, "") or "") for c in dev_cols_export]) ipc = ["ip", "ort_nach_ip", "practices", "sessions_count_approx"] with ip_csv.open("w", newline="", encoding="utf-8-sig") as fh_i: wi = csv.writer(fh_i, delimiter=";") wi.writerow(ipc) for r in bundle.get("ip_summary_rows", []) or []: if isinstance(r, dict): wi.writerow([str(r.get(c, "") or "") for c in ipc]) warn_path.write_text("\n".join(_warnings_for_txt(bundle)).strip() + "\n", encoding="utf-8") def esc_rows(rows: Iterable[Dict[str, Any]], keys: Sequence[str]) -> str: out_ln: List[str] = [] out_ln.append("
Hinweise: keine exakte Adresse aus einer öffentlichen IP ableitbar. " "Optional aufgelöster Ort entspricht meist ISP/Regionalnetz.
" ) htm.append(f"SSH-Label: {html.escape(server_spec)} · Remote: {html.escape(remote_path)}
"
+ html.escape("\n".join(_warnings_for_txt(bundle)))
+ ""
)
sb = bundle.get("stats_block") or {}
htm.append("{html.escape(str(sb.get('notes_summary')))}
{html.escape(str(sb.get('tasks_summary')))}
stripe JSONL-Stichprobe: Zeilen={se.get('lines_seen')} · stripe-artig="
f"{se.get('stripe_like_lines')}
stripe sqlite: {html.escape(str(sb.get('stripe_sqlite')))}