2389 lines
93 KiB
Python
2389 lines
93 KiB
Python
|
|
# -*- 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("<tr>" + "".join(f"<th>{html.escape(str(k))}</th>" for k in keys) + "</tr>")
|
|||
|
|
for rw in rows:
|
|||
|
|
out_ln.append(
|
|||
|
|
"<tr>"
|
|||
|
|
+ "".join(
|
|||
|
|
f"<td>{html.escape(str(((rw.get(k) if isinstance(rw, dict) else '') or '')))}</td>"
|
|||
|
|
for k in keys
|
|||
|
|
)
|
|||
|
|
+ "</tr>"
|
|||
|
|
)
|
|||
|
|
return "\n".join(out_ln)
|
|||
|
|
|
|||
|
|
htm: List[str] = []
|
|||
|
|
htm.append("<!DOCTYPE html><html lang='de'><head><meta charset='utf-8'><title>AZA Kontroll-Report</title>")
|
|||
|
|
htm.append(
|
|||
|
|
"<style>body{font-family:Segoe UI,sans-serif;background:#eaf1f8;color:#1a2a3a;padding:14px;}"
|
|||
|
|
"table{border-collapse:collapse;background:#fff;margin:10px 0;} th,td{border:1px solid #cddbeb;"
|
|||
|
|
"padding:6px 8px;text-align:left;font-size:.85rem;} th{background:#5B8DB3;color:#fff;} "
|
|||
|
|
"h2{font-size:1.05rem;color:#356488;} .yellow{background:#fff8e6;padding:10px;} "
|
|||
|
|
".green{background:#e8f5e9;} .red{background:#fdecea;} .muted{color:#55708a;font-size:.85rem;} </style></head><body>"
|
|||
|
|
)
|
|||
|
|
htm.append("<h1>AZA Kontroll-Hülle — Report</h1>")
|
|||
|
|
htm.append(
|
|||
|
|
"<p>Hinweise: keine exakte Adresse aus einer öffentlichen IP ableitbar. "
|
|||
|
|
"Optional aufgelöster Ort entspricht meist ISP/Regionalnetz.</p>"
|
|||
|
|
)
|
|||
|
|
htm.append(f"<p class='muted'>SSH-Label: <code>{html.escape(server_spec)}</code> · Remote: <code>{html.escape(remote_path)}</code></p>")
|
|||
|
|
htm.append("<h2>Soll-Prüfung</h2>")
|
|||
|
|
for sm in bundle.get("soll_messages", []) or []:
|
|||
|
|
lvl = html.escape(str(sm.get("level", "")))
|
|||
|
|
htm.append(f"<div class='{lvl}'><strong>[{lvl}]</strong> {html.escape(str(sm.get('text','')))}</div>")
|
|||
|
|
htm.append("<h2>Lizenzen (Schlüssel maskiert)</h2><table>")
|
|||
|
|
lk_html = [
|
|||
|
|
"license_key_masked",
|
|||
|
|
"license_suffix",
|
|||
|
|
"license_sha256",
|
|||
|
|
"customer_email_license",
|
|||
|
|
"practice_name",
|
|||
|
|
"practice_id",
|
|||
|
|
"subscription_id",
|
|||
|
|
"woo_order_id",
|
|||
|
|
"lookup_key",
|
|||
|
|
"status",
|
|||
|
|
"current_period_end",
|
|||
|
|
"stripe_letzte_db_aenderung_utc",
|
|||
|
|
"erstes_passendes_stripe_log",
|
|||
|
|
"billing_or_customer_snippet",
|
|||
|
|
"sources",
|
|||
|
|
]
|
|||
|
|
lic_html_rows = []
|
|||
|
|
for rr in bundle.get("license_rows", []) or []:
|
|||
|
|
if not isinstance(rr, dict):
|
|||
|
|
continue
|
|||
|
|
row = dict(rr)
|
|||
|
|
lk = row.get("license_key_plain") or ""
|
|||
|
|
row.pop("license_key_plain", None)
|
|||
|
|
row["license_key_masked"] = _mask_license(str(lk).strip())
|
|||
|
|
lic_html_rows.append(row)
|
|||
|
|
htm.append(esc_rows(lic_html_rows, lk_html))
|
|||
|
|
htm.append("</table><h2>Praxen</h2><table>")
|
|||
|
|
htm.append(esc_rows(bundle.get("practice_rows", []) or [], pcols))
|
|||
|
|
htm.append("</table><h2>Benutzer (Auszug)</h2><table>")
|
|||
|
|
ukeys = ["practice_name", "practice_id", "user_id_short", "display_name", "login_name", "email",
|
|||
|
|
"practice_license_customer_email", "role", "status", "license_key_masked"]
|
|||
|
|
htm.append(esc_rows((bundle.get("users_rows") or [])[:360], ukeys))
|
|||
|
|
htm.append("</table><h2>Sessions (Auszug)</h2><table>")
|
|||
|
|
sess_rows_html = []
|
|||
|
|
for r in (bundle.get("session_rows") or [])[:340]:
|
|||
|
|
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")
|
|||
|
|
sess_rows_html.append(rr)
|
|||
|
|
htm.append(esc_rows(sess_rows_html, sess_cols_export))
|
|||
|
|
htm.append("</table><h2>Geräte (Auszug)</h2><table>")
|
|||
|
|
dev_rows_html = []
|
|||
|
|
for r in (bundle.get("device_rows") or [])[:340]:
|
|||
|
|
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")
|
|||
|
|
dev_rows_html.append(rr)
|
|||
|
|
htm.append(esc_rows(dev_rows_html, dev_cols_export))
|
|||
|
|
htm.append("</table><h2>IP-Zusammenfassung</h2><table>")
|
|||
|
|
htm.append(esc_rows(bundle.get("ip_summary_rows") or [], ipc))
|
|||
|
|
htm.append("</table><h2>Warnungen (maskierte Schlüsselformate)</h2>")
|
|||
|
|
htm.append(
|
|||
|
|
"<pre style='white-space:pre-wrap;background:#fff;padding:12px;border:1px solid #cddbeb;'>"
|
|||
|
|
+ html.escape("\n".join(_warnings_for_txt(bundle)))
|
|||
|
|
+ "</pre>"
|
|||
|
|
)
|
|||
|
|
sb = bundle.get("stats_block") or {}
|
|||
|
|
htm.append("<h2>Zähl-Statistik (keine PHI)</h2>")
|
|||
|
|
htm.append(f"<p><code>{html.escape(str(sb.get('notes_summary')))}</code></p>")
|
|||
|
|
htm.append(f"<p><code>{html.escape(str(sb.get('tasks_summary')))}</code></p>")
|
|||
|
|
se = sb.get("stripe_events") or {}
|
|||
|
|
htm.append(f"<p>stripe JSONL-Stichprobe: Zeilen=<code>{se.get('lines_seen')}</code> · stripe-artig="
|
|||
|
|
f"<code>{se.get('stripe_like_lines')}</code></p>")
|
|||
|
|
htm.append(f"<p>stripe sqlite: <code>{html.escape(str(sb.get('stripe_sqlite')))}</code></p>")
|
|||
|
|
htm.append("</body></html>")
|
|||
|
|
html_path.write_text("\n".join(htm), encoding="utf-8")
|
|||
|
|
|
|||
|
|
readme_path_disk = snap_dir / "README.txt"
|
|||
|
|
readme_path_disk.write_text("\n".join(readme_lines).strip() + "\n", encoding="utf-8")
|
|||
|
|
|
|||
|
|
return inv_path, html_path, warn_path
|
|||
|
|
|
|||
|
|
|
|||
|
|
COPY_BLOCK = REMOTE_CORE_REQUIRED_POST_SCP + REMOTE_COPY_OPTIONAL_ORDERED
|
|||
|
|
|
|||
|
|
|
|||
|
|
class AdminControlShell(tk.Tk):
|
|||
|
|
"""Hauptfenster Tkinter — read-only Kontrolle eines lokal kopierten Snapshots."""
|
|||
|
|
|
|||
|
|
def __init__(self) -> None:
|
|||
|
|
super().__init__()
|
|||
|
|
self.title("AZA Kontroll-Hülle")
|
|||
|
|
self.configure(bg="#eaf1f8")
|
|||
|
|
self.minsize(1080, 700)
|
|||
|
|
|
|||
|
|
self._queue: queue.Queue[tuple[str, Any]] = queue.Queue()
|
|||
|
|
self._server_spec = DEFAULT_SSH_SPEC
|
|||
|
|
self._remote_data = DEFAULT_REMOTE_DATA
|
|||
|
|
self._snap_root = _SNAP_ROOT_WIN
|
|||
|
|
self._current_snap: Optional[Path] = None
|
|||
|
|
self._last_bundle: Optional[Dict[str, Any]] = None
|
|||
|
|
|
|||
|
|
self._ssh_ok = tk.StringVar(value="unbekannt")
|
|||
|
|
self._ro_mode = tk.StringVar(value="READ-ONLY")
|
|||
|
|
self._last_snap_var = tk.StringVar(value="keiner")
|
|||
|
|
self._soft_notes_var = tk.StringVar(value="")
|
|||
|
|
|
|||
|
|
self._ip_geo_cache: Dict[str, str] = {}
|
|||
|
|
self._ip_disclaimed = False
|
|||
|
|
self._var_show_full_license = tk.BooleanVar(value=True)
|
|||
|
|
self._tree_sort_state: Dict[int, Dict[str, Any]] = {}
|
|||
|
|
self._tree_clipboard_bound = False
|
|||
|
|
|
|||
|
|
self._build_ui()
|
|||
|
|
self.after(160, self._poll_queue)
|
|||
|
|
|
|||
|
|
# --- UI-Aufbau ---
|
|||
|
|
def _build_ui(self) -> None:
|
|||
|
|
hdr = tk.Frame(self, bg="#eaf1f8")
|
|||
|
|
hdr.pack(fill="x", padx=12, pady=(10, 6))
|
|||
|
|
tk.Label(hdr, text="AZA Kontroll-Hülle", font=("Segoe UI", 16, "bold"), fg="#356488", bg="#eaf1f8").pack(side="left")
|
|||
|
|
bf = tk.Frame(hdr, bg="#eaf1f8")
|
|||
|
|
bf.pack(side="right")
|
|||
|
|
tk.Button(bf, text="Hetzner-Daten aktualisieren", bg="#5B8DB3", fg="white",
|
|||
|
|
relief="flat", padx=12, pady=6, command=self._on_refresh_remote, cursor="hand2").pack(side="left", padx=4)
|
|||
|
|
tk.Button(bf, text="Snapshot öffnen", relief="flat", padx=10, pady=6, bg="#dceaf4",
|
|||
|
|
command=self._on_open_manual, cursor="hand2").pack(side="left", padx=4)
|
|||
|
|
tk.Button(bf, text="Report exportieren", relief="flat", padx=10, pady=6, bg="#dceaf4",
|
|||
|
|
command=self._on_export_reports, cursor="hand2").pack(side="left", padx=4)
|
|||
|
|
tk.Checkbutton(bf, text="Lizenzen komplett anzeigen", variable=self._var_show_full_license,
|
|||
|
|
bg="#eaf1f8", command=self._on_toggle_license).pack(side="left", padx=10)
|
|||
|
|
|
|||
|
|
stf = tk.Frame(self, bg="#dceaf4", highlightthickness=1, highlightbackground="#c8dae8")
|
|||
|
|
stf.pack(fill="x", padx=12, pady=(0, 6))
|
|||
|
|
tk.Label(stf, textvariable=self._last_snap_var, fg="#334e66", bg="#dceaf4",
|
|||
|
|
font=("Segoe UI", 9)).pack(side="left", padx=8, pady=4)
|
|||
|
|
tk.Label(stf, text=" | SSH/OpenSSH: ", fg="#334e66", bg="#dceaf4").pack(side="left")
|
|||
|
|
tk.Label(stf, textvariable=self._ssh_ok, fg="#334e66", bg="#dceaf4",
|
|||
|
|
font=("Segoe UI", 9, "bold")).pack(side="left")
|
|||
|
|
tk.Label(stf, text=" | SCP-Hinweise: ", fg="#334e66", bg="#dceaf4").pack(side="left")
|
|||
|
|
tk.Label(stf, textvariable=self._soft_notes_var, fg="#6a5720", bg="#dceaf4",
|
|||
|
|
wraplength=480, justify="left", font=("Segoe UI", 8)).pack(side="left", padx=4)
|
|||
|
|
tk.Label(stf, text=" | ", fg="#334e66", bg="#dceaf4").pack(side="left")
|
|||
|
|
tk.Label(stf, textvariable=self._ro_mode, fg="#2E7D32", bg="#dceaf4",
|
|||
|
|
font=("Segoe UI", 9, "bold")).pack(side="left", padx=4)
|
|||
|
|
|
|||
|
|
self._nb = ttk.Notebook(self)
|
|||
|
|
self._nb.pack(fill="both", expand=True, padx=10, pady=(0, 12))
|
|||
|
|
|
|||
|
|
self._tree_practices = self._mk_plain_tree(self._nb, "Praxen")
|
|||
|
|
self._tree_users = self._mk_plain_tree(self._nb, "Benutzer")
|
|||
|
|
self._text_admins = self._mk_text(self._nb, "Admin-Kontrolle")
|
|||
|
|
|
|||
|
|
lf = tk.Frame(self._nb, bg="#eaf1f8")
|
|||
|
|
self._nb.add(lf, text="Lizenzen")
|
|||
|
|
lf_top = tk.Frame(lf, bg="#eaf1f8")
|
|||
|
|
lf_top.pack(fill="x", padx=4, pady=2)
|
|||
|
|
tk.Label(lf_top, bg="#eaf1f8", fg="#54708f", font=("Segoe UI", 8),
|
|||
|
|
wraplength=900, justify="left",
|
|||
|
|
text="Quellen kombiniert: stripe_webhook.sqlite, stripe_events.log.jsonl, empfang_accounts.json. "
|
|||
|
|
"„Rechnungs-/Kundenstichwort“ kann von Stripe-Adresse kommen ≠ Registrierungsort.").pack(anchor="w")
|
|||
|
|
lf_body = tk.Frame(lf, bg="#eaf1f8")
|
|||
|
|
lf_body.pack(fill="both", expand=True)
|
|||
|
|
self._tv_license = ttk.Treeview(lf_body, columns=(), show="headings")
|
|||
|
|
yl = ttk.Scrollbar(lf_body, orient="vertical", command=self._tv_license.yview)
|
|||
|
|
self._tv_license.configure(yscrollcommand=yl.set)
|
|||
|
|
self._tv_license.grid(row=0, column=0, sticky="nsew")
|
|||
|
|
yl.grid(row=0, column=1, sticky="ns")
|
|||
|
|
lf_body.grid_rowconfigure(0, weight=1)
|
|||
|
|
lf_body.grid_columnconfigure(0, weight=1)
|
|||
|
|
|
|||
|
|
self._tree_links = self._mk_plain_tree(self._nb, "Codes / Verbindungen")
|
|||
|
|
|
|||
|
|
sf = tk.Frame(self._nb, bg="#eaf1f8")
|
|||
|
|
self._nb.add(sf, text="Sessions")
|
|||
|
|
sbt = tk.Frame(sf, bg="#eaf1f8")
|
|||
|
|
sbt.pack(fill="x", padx=4, pady=2)
|
|||
|
|
tk.Button(sbt, text="IP-Orte auflösen (optional, nachfragen)", bg="#dceaf4", command=self._on_resolve_ips).pack(side="left")
|
|||
|
|
tk.Label(sbt, bg="#eaf1f8", fg="#667e93", font=("Segoe UI", 8),
|
|||
|
|
text=" Kein Auto beim Start · nur näherungsweise Stadt/ISP · keine exakte Hausadresse.").pack(side="left", padx=6)
|
|||
|
|
sfb = tk.Frame(sf, bg="#eaf1f8")
|
|||
|
|
sfb.pack(fill="both", expand=True)
|
|||
|
|
self._tv_sess = ttk.Treeview(sfb, columns=(), show="headings")
|
|||
|
|
ys = ttk.Scrollbar(sfb, orient="vertical", command=self._tv_sess.yview)
|
|||
|
|
self._tv_sess.configure(yscrollcommand=ys.set)
|
|||
|
|
self._tv_sess.grid(row=0, column=0, sticky="nsew")
|
|||
|
|
ys.grid(row=0, column=1, sticky="ns")
|
|||
|
|
sfb.grid_rowconfigure(0, weight=1)
|
|||
|
|
sfb.grid_columnconfigure(0, weight=1)
|
|||
|
|
|
|||
|
|
df = tk.Frame(self._nb, bg="#eaf1f8")
|
|||
|
|
self._nb.add(df, text="Geräte")
|
|||
|
|
dt = tk.Frame(df, bg="#eaf1f8")
|
|||
|
|
dt.pack(fill="x", padx=4)
|
|||
|
|
tk.Label(dt, bg="#eaf1f8", fg="#667e93", font=("Segoe UI", 8),
|
|||
|
|
text="IP-Ort-Spalten nutzen denselben Button wie beim Sessions‑Tab.").pack(anchor="w")
|
|||
|
|
df_b = tk.Frame(df, bg="#eaf1f8")
|
|||
|
|
df_b.pack(fill="both", expand=True)
|
|||
|
|
self._tv_dev = ttk.Treeview(df_b, columns=(), show="headings")
|
|||
|
|
yd = ttk.Scrollbar(df_b, orient="vertical", command=self._tv_dev.yview)
|
|||
|
|
self._tv_dev.configure(yscrollcommand=yd.set)
|
|||
|
|
self._tv_dev.grid(row=0, column=0, sticky="nsew")
|
|||
|
|
yd.grid(row=0, column=1, sticky="ns")
|
|||
|
|
df_b.grid_rowconfigure(0, weight=1)
|
|||
|
|
df_b.grid_columnconfigure(0, weight=1)
|
|||
|
|
|
|||
|
|
kib = tk.Frame(self._nb, bg="#eaf1f8")
|
|||
|
|
self._nb.add(kib, text="KI-Budget (API)")
|
|||
|
|
kt = tk.Frame(kib, bg="#eaf1f8")
|
|||
|
|
kt.pack(fill="x", padx=6, pady=4)
|
|||
|
|
tk.Label(kt, text="API-Basis", bg="#eaf1f8", font=("Segoe UI", 9)).pack(side="left")
|
|||
|
|
self._var_ai_api_base = tk.StringVar(
|
|||
|
|
value=os.environ.get("AZA_ADMIN_API_BASE", "https://api.aza-medwork.ch").strip()
|
|||
|
|
)
|
|||
|
|
tk.Entry(kt, textvariable=self._var_ai_api_base, width=48).pack(side="left", padx=6)
|
|||
|
|
tk.Label(kt, text="X-Admin-Token", bg="#eaf1f8", font=("Segoe UI", 9)).pack(side="left", padx=(10, 0))
|
|||
|
|
self._var_ai_admin_tok = tk.StringVar(value=os.environ.get("AZA_ADMIN_TOKEN", "").strip())
|
|||
|
|
tk.Entry(kt, textvariable=self._var_ai_admin_tok, width=32, show="*").pack(side="left", padx=6)
|
|||
|
|
tk.Button(
|
|||
|
|
kt, text="Übersicht laden", command=self._on_load_ai_budget,
|
|||
|
|
bg="#5B8DB3", fg="white", relief="flat", padx=10, pady=4,
|
|||
|
|
).pack(side="left", padx=6)
|
|||
|
|
tk.Button(
|
|||
|
|
kt, text="CSV speichern…", command=self._on_export_ai_budget_csv,
|
|||
|
|
bg="#dceaf4", relief="flat", padx=10, pady=4,
|
|||
|
|
).pack(side="left", padx=4)
|
|||
|
|
khelp = tk.Frame(kib, bg="#eaf1f8")
|
|||
|
|
khelp.pack(fill="x", padx=8)
|
|||
|
|
tk.Label(
|
|||
|
|
khelp,
|
|||
|
|
bg="#eaf1f8",
|
|||
|
|
fg="#667e93",
|
|||
|
|
font=("Segoe UI", 8),
|
|||
|
|
justify="left",
|
|||
|
|
text="GET /admin/ai_budget_overview · CSV /admin/ai_budget_export.csv · nur lesend, Admin-Token wie andere /admin/*-Routen.",
|
|||
|
|
wraplength=980,
|
|||
|
|
).pack(anchor="w")
|
|||
|
|
kb = tk.Frame(kib, bg="#eaf1f8")
|
|||
|
|
kb.pack(fill="both", expand=True, padx=4, pady=(0, 6))
|
|||
|
|
self._txt_ai_budget = scrolledtext.ScrolledText(
|
|||
|
|
kb, wrap="word", font=("Consolas", 9), bg="#fdfefe", fg="#1a3550"
|
|||
|
|
)
|
|||
|
|
self._txt_ai_budget.pack(fill="both", expand=True)
|
|||
|
|
|
|||
|
|
self._diag = self._mk_text(self._nb, "Diagnose")
|
|||
|
|
|
|||
|
|
self._footer()
|
|||
|
|
self._check_ssh_clients()
|
|||
|
|
self._wire_tree_clipboard_sort()
|
|||
|
|
|
|||
|
|
def _footer(self) -> None:
|
|||
|
|
tk.Label(
|
|||
|
|
self,
|
|||
|
|
fg="#667e93",
|
|||
|
|
bg="#eaf1f8",
|
|||
|
|
font=("Segoe UI", 8),
|
|||
|
|
justify="left",
|
|||
|
|
wraplength=1040,
|
|||
|
|
text="Snapshots: Documents\\AzA Drive\\AZA_CONTROL_SNAPSHOTS · OpenSSH ssh/scp (Schlüssel/Agent, kein PW-Speicher). "
|
|||
|
|
"EXE nutzt ebenfalls externes Windows-OpenSSH.",
|
|||
|
|
).pack(fill="x", padx=14, pady=(0, 8))
|
|||
|
|
|
|||
|
|
def _mk_plain_tree(self, nb: ttk.Notebook, title: str) -> ttk.Treeview:
|
|||
|
|
fr = tk.Frame(nb, bg="#eaf1f8")
|
|||
|
|
nb.add(fr, text=title)
|
|||
|
|
wrap = tk.Frame(fr, bg="#eaf1f8")
|
|||
|
|
wrap.pack(fill="both", expand=True)
|
|||
|
|
tv = ttk.Treeview(wrap, columns=(), show="headings")
|
|||
|
|
ys = ttk.Scrollbar(wrap, orient="vertical", command=tv.yview)
|
|||
|
|
tv.configure(yscrollcommand=ys.set)
|
|||
|
|
tv.grid(row=0, column=0, sticky="nsew")
|
|||
|
|
ys.grid(row=0, column=1, sticky="ns")
|
|||
|
|
wrap.grid_rowconfigure(0, weight=1)
|
|||
|
|
wrap.grid_columnconfigure(0, weight=1)
|
|||
|
|
return tv
|
|||
|
|
|
|||
|
|
def _mk_text(self, nb: ttk.Notebook, title: str) -> scrolledtext.ScrolledText:
|
|||
|
|
fr = tk.Frame(nb, bg="#eaf1f8")
|
|||
|
|
nb.add(fr, text=title)
|
|||
|
|
txt = scrolledtext.ScrolledText(fr, wrap="word", font=("Consolas", 9), bg="#fdfefe", fg="#1a3550")
|
|||
|
|
txt.pack(fill="both", expand=True, padx=4, pady=4)
|
|||
|
|
txt.configure(state="disabled")
|
|||
|
|
return txt
|
|||
|
|
|
|||
|
|
def _check_ssh_clients(self) -> None:
|
|||
|
|
ok_scp = shutil_which("scp") is not None
|
|||
|
|
ok_ssh = shutil_which("ssh") is not None
|
|||
|
|
if ok_ssh and ok_scp:
|
|||
|
|
self._ssh_ok.set("scp/ssh vorhanden")
|
|||
|
|
else:
|
|||
|
|
miss = []
|
|||
|
|
if not ok_ssh:
|
|||
|
|
miss.append("ssh")
|
|||
|
|
if not ok_scp:
|
|||
|
|
miss.append("scp")
|
|||
|
|
self._ssh_ok.set("fehlt: " + ",".join(miss))
|
|||
|
|
|
|||
|
|
def _poll_queue(self) -> None:
|
|||
|
|
try:
|
|||
|
|
while True:
|
|||
|
|
kind, payload = self._queue.get_nowait()
|
|||
|
|
self._dispatch_queue(kind, payload)
|
|||
|
|
except queue.Empty:
|
|||
|
|
pass
|
|||
|
|
self.after(260, self._poll_queue)
|
|||
|
|
|
|||
|
|
def _dispatch_queue(self, kind: str, payload: Any) -> None:
|
|||
|
|
if kind == "toast":
|
|||
|
|
self._set_status(str(payload))
|
|||
|
|
elif kind == "ai_budget_text":
|
|||
|
|
try:
|
|||
|
|
self._txt_ai_budget.configure(state="normal")
|
|||
|
|
self._txt_ai_budget.delete("1.0", "end")
|
|||
|
|
self._txt_ai_budget.insert("1.0", str(payload))
|
|||
|
|
self._txt_ai_budget.configure(state="disabled")
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
elif kind == "snap_done":
|
|||
|
|
path, bundle, opt_notes = payload
|
|||
|
|
self._current_snap = Path(path)
|
|||
|
|
self._last_bundle = bundle
|
|||
|
|
self._ip_geo_cache.clear()
|
|||
|
|
self._inject_geo(bundle)
|
|||
|
|
self._populate_all(bundle)
|
|||
|
|
self._last_snap_var.set(str(self._current_snap.resolve()))
|
|||
|
|
txt = "; ".join(opt_notes[:6])[:520] + ("…" if len(opt_notes) > 6 else "")
|
|||
|
|
self._soft_notes_var.set(txt or "(optional vollständig)")
|
|||
|
|
self._ssh_ok.set("scp/ssh – Snapshot gelesen")
|
|||
|
|
elif kind == "snap_fail":
|
|||
|
|
self._ssh_ok.set("Fehler / nicht erreichbar")
|
|||
|
|
messagebox.showerror("Snapshot fehlgeschlagen", str(payload))
|
|||
|
|
elif kind == "ip_done":
|
|||
|
|
self._inject_geo(self._last_bundle or {})
|
|||
|
|
if self._last_bundle:
|
|||
|
|
self._populate_sessions_devices(self._last_bundle)
|
|||
|
|
self._set_status("IP-Näherungsdaten angefordert")
|
|||
|
|
|
|||
|
|
def _on_load_ai_budget(self) -> None:
|
|||
|
|
base = (self._var_ai_api_base.get() or "").strip().rstrip("/")
|
|||
|
|
tok = (self._var_ai_admin_tok.get() or "").strip()
|
|||
|
|
if not base or not tok:
|
|||
|
|
messagebox.showwarning("KI-Budget", "API-Basis und Admin-Token angeben.", parent=self)
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
def worker():
|
|||
|
|
try:
|
|||
|
|
url = base + "/admin/ai_budget_overview?status=active"
|
|||
|
|
req = urllib.request.Request(url, headers={"X-Admin-Token": tok})
|
|||
|
|
with urllib.request.urlopen(req, timeout=90) as resp:
|
|||
|
|
body = resp.read().decode("utf-8", errors="replace")
|
|||
|
|
data = json.loads(body)
|
|||
|
|
pretty = json.dumps(data, ensure_ascii=False, indent=2)
|
|||
|
|
self._queue.put(("ai_budget_text", pretty))
|
|||
|
|
except Exception as e:
|
|||
|
|
self._queue.put(("ai_budget_text", f"FEHLER: {e}"))
|
|||
|
|
|
|||
|
|
threading.Thread(target=worker, daemon=True).start()
|
|||
|
|
|
|||
|
|
def _on_export_ai_budget_csv(self) -> None:
|
|||
|
|
base = (self._var_ai_api_base.get() or "").strip().rstrip("/")
|
|||
|
|
tok = (self._var_ai_admin_tok.get() or "").strip()
|
|||
|
|
if not base or not tok:
|
|||
|
|
messagebox.showwarning("KI-Budget", "API-Basis und Admin-Token angeben.", parent=self)
|
|||
|
|
return
|
|||
|
|
path = filedialog.asksaveasfilename(
|
|||
|
|
parent=self,
|
|||
|
|
defaultextension=".csv",
|
|||
|
|
filetypes=[("CSV", "*.csv"), ("Alle", "*.*")],
|
|||
|
|
title="KI-Budget CSV speichern",
|
|||
|
|
)
|
|||
|
|
if not path:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
def worker():
|
|||
|
|
try:
|
|||
|
|
url = base + "/admin/ai_budget_export.csv?status=active"
|
|||
|
|
req = urllib.request.Request(url, headers={"X-Admin-Token": tok})
|
|||
|
|
with urllib.request.urlopen(req, timeout=120) as resp:
|
|||
|
|
raw = resp.read()
|
|||
|
|
Path(path).write_bytes(raw)
|
|||
|
|
self._queue.put(("toast", f"CSV geschrieben: {path}"))
|
|||
|
|
except Exception as e:
|
|||
|
|
self._queue.put(("toast", f"CSV-Fehler: {e}"))
|
|||
|
|
|
|||
|
|
threading.Thread(target=worker, daemon=True).start()
|
|||
|
|
|
|||
|
|
def _set_status(self, msg: str) -> None:
|
|||
|
|
self.title("AZA Kontroll-Hülle — " + msg[:78])
|
|||
|
|
|
|||
|
|
def _inject_geo(self, bundle: Mapping[str, Any]) -> None:
|
|||
|
|
cmap = self._ip_geo_cache
|
|||
|
|
if not cmap:
|
|||
|
|
return
|
|||
|
|
br = bundle # mutate ok
|
|||
|
|
for r in list(br.get("session_rows") or []):
|
|||
|
|
if isinstance(r, dict):
|
|||
|
|
ipa = str(r.get("ip_address") or "").strip()
|
|||
|
|
if ipa in cmap:
|
|||
|
|
r["ort_nach_ip"] = cmap[ipa]
|
|||
|
|
for r in list(br.get("device_rows") or []):
|
|||
|
|
if isinstance(r, dict):
|
|||
|
|
ipa = str(r.get("ip_address") or "").strip()
|
|||
|
|
if ipa in cmap:
|
|||
|
|
r["ort_nach_ip"] = cmap[ipa]
|
|||
|
|
for r in list(br.get("ip_summary_rows") or []):
|
|||
|
|
if isinstance(r, dict):
|
|||
|
|
ipa = str(r.get("ip") or "").strip()
|
|||
|
|
if ipa in cmap:
|
|||
|
|
r["ort_nach_ip"] = cmap[ipa]
|
|||
|
|
|
|||
|
|
# --- Trees ---
|
|||
|
|
def _wire_tree_clipboard_sort(self) -> None:
|
|||
|
|
if self._tree_clipboard_bound:
|
|||
|
|
return
|
|||
|
|
self._tree_clipboard_bound = True
|
|||
|
|
self.bind_all("<Control-c>", self._on_tree_ctrl_c, add="+")
|
|||
|
|
self.bind_class("Treeview", "<Double-1>", self._on_tree_double_cell, add="+")
|
|||
|
|
|
|||
|
|
def _on_tree_ctrl_c(self, event: tk.Event) -> Optional[str]:
|
|||
|
|
w = self.focus_get()
|
|||
|
|
if not isinstance(w, ttk.Treeview):
|
|||
|
|
return None
|
|||
|
|
cols = getattr(w, "_cols_order", None)
|
|||
|
|
if not cols:
|
|||
|
|
return None
|
|||
|
|
sel = w.selection()
|
|||
|
|
if not sel:
|
|||
|
|
return None
|
|||
|
|
lines: List[str] = []
|
|||
|
|
for iid in sel:
|
|||
|
|
vals = w.item(iid, "values")
|
|||
|
|
lines.append("\t".join(str(x) for x in vals))
|
|||
|
|
try:
|
|||
|
|
self.clipboard_clear()
|
|||
|
|
self.clipboard_append("\n".join(lines))
|
|||
|
|
except tk.TclError:
|
|||
|
|
pass
|
|||
|
|
return "break"
|
|||
|
|
|
|||
|
|
def _on_tree_double_cell(self, event: tk.Event) -> None:
|
|||
|
|
w = event.widget
|
|||
|
|
if not isinstance(w, ttk.Treeview):
|
|||
|
|
return
|
|||
|
|
cols = getattr(w, "_cols_order", None)
|
|||
|
|
if not cols:
|
|||
|
|
return
|
|||
|
|
row_id = w.identify_row(event.y)
|
|||
|
|
col_id = w.identify_column(event.x)
|
|||
|
|
if not row_id or not col_id or col_id == "#0":
|
|||
|
|
return
|
|||
|
|
try:
|
|||
|
|
idx = int(col_id.replace("#", "")) - 1
|
|||
|
|
except ValueError:
|
|||
|
|
return
|
|||
|
|
if idx < 0 or idx >= len(cols):
|
|||
|
|
return
|
|||
|
|
vals = w.item(row_id, "values")
|
|||
|
|
if idx >= len(vals):
|
|||
|
|
return
|
|||
|
|
try:
|
|||
|
|
self.clipboard_clear()
|
|||
|
|
self.clipboard_append(str(vals[idx]))
|
|||
|
|
except tk.TclError:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
def _clear_fill(self, tv: ttk.Treeview, cols: Sequence[str], rows: Sequence[Any]) -> None:
|
|||
|
|
col_list = list(cols)
|
|||
|
|
tv.delete(*tv.get_children())
|
|||
|
|
tv.configure(columns=col_list)
|
|||
|
|
norm: List[Dict[str, Any]] = []
|
|||
|
|
for rw in rows:
|
|||
|
|
if isinstance(rw, dict):
|
|||
|
|
norm.append(dict(rw))
|
|||
|
|
else:
|
|||
|
|
norm.append({c: str(rw) for c in col_list})
|
|||
|
|
tv._cols_order = col_list # type: ignore[attr-defined]
|
|||
|
|
tv._rows_raw = norm # type: ignore[attr-defined]
|
|||
|
|
|
|||
|
|
sid = id(tv)
|
|||
|
|
self._tree_sort_state[sid] = {"col": None, "reverse": False}
|
|||
|
|
st = self._tree_sort_state[sid]
|
|||
|
|
|
|||
|
|
def refill_from_sorted(sorted_rows: List[Dict[str, Any]]) -> None:
|
|||
|
|
tv.delete(*tv.get_children())
|
|||
|
|
tv._rows_raw = sorted_rows # type: ignore[attr-defined]
|
|||
|
|
for rw in sorted_rows:
|
|||
|
|
tv.insert("", "end", values=tuple(str(rw.get(c, "") or "") for c in col_list))
|
|||
|
|
|
|||
|
|
def on_head(col: str) -> None:
|
|||
|
|
if st.get("col") == col:
|
|||
|
|
st["reverse"] = not bool(st.get("reverse"))
|
|||
|
|
else:
|
|||
|
|
st["col"] = col
|
|||
|
|
st["reverse"] = False
|
|||
|
|
rev = bool(st.get("reverse"))
|
|||
|
|
cur = list(getattr(tv, "_rows_raw", []) or [])
|
|||
|
|
|
|||
|
|
def sk(r: Dict[str, Any]) -> Tuple[Any, ...]:
|
|||
|
|
return _tree_sort_key_tuple(col, str(r.get(col, "") or ""))
|
|||
|
|
|
|||
|
|
cur.sort(key=sk, reverse=rev)
|
|||
|
|
refill_from_sorted(cur)
|
|||
|
|
|
|||
|
|
for c in col_list:
|
|||
|
|
tv.heading(c, text=_tree_column_heading(c), command=lambda cc=c: on_head(cc))
|
|||
|
|
tv.column(c, width=126, anchor="w")
|
|||
|
|
refill_from_sorted(norm)
|
|||
|
|
|
|||
|
|
def _on_toggle_license(self) -> None:
|
|||
|
|
if self._last_bundle:
|
|||
|
|
self._populate_licenses(dict(self._last_bundle))
|
|||
|
|
|
|||
|
|
def _license_cell(self, r: Mapping[str, Any]) -> str:
|
|||
|
|
full = bool(self._var_show_full_license.get())
|
|||
|
|
lk = str((r.get("license_key_plain") if isinstance(r, dict) else "") or "").strip()
|
|||
|
|
mk = str((r.get("license_key_masked") if isinstance(r, dict) else "") or "").strip()
|
|||
|
|
if full and lk:
|
|||
|
|
return lk
|
|||
|
|
return mk or _mask_license(lk)
|
|||
|
|
|
|||
|
|
def _populate_licenses(self, bundle: Mapping[str, Any]) -> None:
|
|||
|
|
cols = [
|
|||
|
|
"license_key_display",
|
|||
|
|
"license_suffix",
|
|||
|
|
"license_sha256",
|
|||
|
|
"customer_email_license",
|
|||
|
|
"practice_name",
|
|||
|
|
"practice_id",
|
|||
|
|
"subscription_id",
|
|||
|
|
"woo_order_id",
|
|||
|
|
"stripe_customer_id",
|
|||
|
|
"lookup_key",
|
|||
|
|
"status",
|
|||
|
|
"current_period_end",
|
|||
|
|
"stripe_letzte_db_aenderung_utc",
|
|||
|
|
"erstes_passendes_stripe_log",
|
|||
|
|
"billing_or_customer_snippet",
|
|||
|
|
"sources",
|
|||
|
|
]
|
|||
|
|
rows: List[Dict[str, Any]] = []
|
|||
|
|
for rr in bundle.get("license_rows", []) or []:
|
|||
|
|
if not isinstance(rr, dict):
|
|||
|
|
continue
|
|||
|
|
row = dict(rr)
|
|||
|
|
row["license_key_display"] = self._license_cell(row)
|
|||
|
|
rows.append(row)
|
|||
|
|
self._clear_fill(self._tv_license, cols, rows)
|
|||
|
|
|
|||
|
|
def _populate_sessions_devices(self, bundle: Mapping[str, Any]) -> None:
|
|||
|
|
scols = [
|
|||
|
|
"idx",
|
|||
|
|
"practice_name",
|
|||
|
|
"practice_id",
|
|||
|
|
"display_name",
|
|||
|
|
"user_id_short",
|
|||
|
|
"session_role",
|
|||
|
|
"account_role",
|
|||
|
|
"ip_address",
|
|||
|
|
"ort_nach_ip",
|
|||
|
|
"first_seen",
|
|||
|
|
"last_seen",
|
|||
|
|
"device_id_short",
|
|||
|
|
"browser_os",
|
|||
|
|
"mismatch",
|
|||
|
|
"user_agent",
|
|||
|
|
"source",
|
|||
|
|
]
|
|||
|
|
s_rows: List[Dict[str, Any]] = []
|
|||
|
|
for r in bundle.get("session_rows", []) or []:
|
|||
|
|
if not isinstance(r, dict):
|
|||
|
|
continue
|
|||
|
|
rr = dict(r)
|
|||
|
|
rr["source"] = rr.get("_source_row", "empfang_sessions.json")
|
|||
|
|
if not rr.get("ort_nach_ip"):
|
|||
|
|
rr["ort_nach_ip"] = "nicht aufgelöst"
|
|||
|
|
s_rows.append(rr)
|
|||
|
|
self._clear_fill(self._tv_sess, scols, s_rows)
|
|||
|
|
|
|||
|
|
dcols = [
|
|||
|
|
"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",
|
|||
|
|
]
|
|||
|
|
d_rows = []
|
|||
|
|
for r in bundle.get("device_rows", []) or []:
|
|||
|
|
if not isinstance(r, dict):
|
|||
|
|
continue
|
|||
|
|
rr = dict(r)
|
|||
|
|
rr["source"] = rr.get("_source_row", "empfang_devices.json")
|
|||
|
|
if not rr.get("ort_nach_ip"):
|
|||
|
|
rr["ort_nach_ip"] = "nicht aufgelöst"
|
|||
|
|
d_rows.append(rr)
|
|||
|
|
self._clear_fill(self._tv_dev, dcols, d_rows)
|
|||
|
|
|
|||
|
|
def _populate_all(self, bundle: Dict[str, Any]) -> None:
|
|||
|
|
p_cols = [
|
|||
|
|
"practice_name",
|
|||
|
|
"practice_id",
|
|||
|
|
"invite_code",
|
|||
|
|
"user_count",
|
|||
|
|
"admin_count",
|
|||
|
|
"admin_names",
|
|||
|
|
"admin_account_emails",
|
|||
|
|
"practice_meta_email",
|
|||
|
|
"license_customer_email",
|
|||
|
|
"hints",
|
|||
|
|
]
|
|||
|
|
self._clear_fill(self._tree_practices, p_cols, bundle.get("practice_rows") or [])
|
|||
|
|
u_cols = [
|
|||
|
|
"practice_name",
|
|||
|
|
"practice_id",
|
|||
|
|
"user_id_short",
|
|||
|
|
"display_name",
|
|||
|
|
"login_name",
|
|||
|
|
"email",
|
|||
|
|
"practice_license_customer_email",
|
|||
|
|
"role",
|
|||
|
|
"admin_source",
|
|||
|
|
"status",
|
|||
|
|
"updated",
|
|||
|
|
"license_key_masked",
|
|||
|
|
]
|
|||
|
|
u_vis = []
|
|||
|
|
for rr in bundle.get("users_rows", []) or []:
|
|||
|
|
rw = dict(rr)
|
|||
|
|
lk_plain = rw.get("license_key_plain") or ""
|
|||
|
|
rw.pop("license_key_plain", None)
|
|||
|
|
mm = _mask_license(str(lk_plain).strip())
|
|||
|
|
if bool(self._var_show_full_license.get()):
|
|||
|
|
rw["license_key_masked"] = str(lk_plain).strip() or mm or "—"
|
|||
|
|
else:
|
|||
|
|
rw["license_key_masked"] = mm or "—"
|
|||
|
|
u_vis.append(rw)
|
|||
|
|
self._clear_fill(self._tree_users, u_cols, u_vis)
|
|||
|
|
|
|||
|
|
adm_lines = ["=== Nach Praxis gruppierte Admins (read-only Konten-JSON) ===\n"]
|
|||
|
|
byp: Dict[str, List[str]] = defaultdict(list)
|
|||
|
|
for rr in bundle.get("users_rows", []) or []:
|
|||
|
|
if str(rr.get("role")).lower() == "admin":
|
|||
|
|
byp[str(rr.get("practice_id"))].append(
|
|||
|
|
f" • {rr.get('display_name')} (login={rr.get('login_name')}, Konto-Mail={rr.get('email')}, "
|
|||
|
|
f"Praxis-Mail-Lizenz={rr.get('practice_license_customer_email')})"
|
|||
|
|
)
|
|||
|
|
pname_map = {r["practice_id"]: r.get("practice_name", "") for r in bundle.get("practice_rows", []) or []}
|
|||
|
|
for pid in sorted(byp.keys(), key=lambda x: (str(pname_map.get(x)), x)):
|
|||
|
|
admins = byp.get(pid, [])
|
|||
|
|
adm_lines.append(f"\n[{pid}] {pname_map.get(pid, '')}")
|
|||
|
|
if not admins:
|
|||
|
|
adm_lines.append(" (!) keine Admins")
|
|||
|
|
else:
|
|||
|
|
if len(admins) > 1:
|
|||
|
|
adm_lines.append(f" (!) mehrere ({len(admins)}):")
|
|||
|
|
for ln in admins:
|
|||
|
|
adm_lines.append(str(ln))
|
|||
|
|
adm_out = "\n".join(adm_lines)
|
|||
|
|
if not byp:
|
|||
|
|
adm_out += "\nKeine Konten sind als Rolle »admin« markiert.\n"
|
|||
|
|
self._set_text_widget(self._text_admins, adm_out)
|
|||
|
|
|
|||
|
|
self._populate_licenses(bundle)
|
|||
|
|
self._populate_sessions_devices(bundle)
|
|||
|
|
link_cols = [
|
|||
|
|
"status",
|
|||
|
|
"contact_type",
|
|||
|
|
"source_practice",
|
|||
|
|
"target_practice",
|
|||
|
|
"invite_used",
|
|||
|
|
"created_at",
|
|||
|
|
"direction",
|
|||
|
|
"link_id_short",
|
|||
|
|
]
|
|||
|
|
self._clear_fill(self._tree_links, link_cols, bundle.get("link_rows", []) or [])
|
|||
|
|
|
|||
|
|
diag = ["=== Automatisch ermittelte Warnungen/Hinweise (maskierte Lizenztokens) ===\n"]
|
|||
|
|
for ln in bundle.get("warnings") or []:
|
|||
|
|
diag.append("- " + ln)
|
|||
|
|
diag.append("\n=== Soll-Prüfung ===")
|
|||
|
|
for sm in bundle.get("soll_messages") or []:
|
|||
|
|
diag.append(f"[{sm.get('level','')}] {sm.get('text','')}")
|
|||
|
|
diag.append("\n=== stripe / notes / tasks Zählwerte ===")
|
|||
|
|
st = bundle.get("stats_block") or {}
|
|||
|
|
diag.append(str(st))
|
|||
|
|
diag.append("\nExakte Hausadresse ist aus öffentlicher IP typischerweise nicht ableitbar; optionaler Button nutzt Fremd-API (näherungsweise Stadt/ISP).")
|
|||
|
|
|
|||
|
|
self._set_text_widget(self._diag, "\n".join(diag))
|
|||
|
|
|
|||
|
|
def _set_text_widget(self, w: scrolledtext.ScrolledText, content: str) -> None:
|
|||
|
|
w.configure(state="normal")
|
|||
|
|
w.delete("1.0", tk.END)
|
|||
|
|
w.insert(tk.END, content)
|
|||
|
|
w.configure(state="disabled")
|
|||
|
|
|
|||
|
|
def _gather_ips_from_bundle(self) -> List[str]:
|
|||
|
|
if not self._last_bundle:
|
|||
|
|
return []
|
|||
|
|
ips: set[str] = set()
|
|||
|
|
for rr in list(self._last_bundle.get("session_rows") or []) + list(self._last_bundle.get("device_rows") or []):
|
|||
|
|
if isinstance(rr, dict):
|
|||
|
|
ipa = str(rr.get("ip_address") or "").strip()
|
|||
|
|
if _IP_RE.fullmatch(ipa):
|
|||
|
|
ips.add(ipa)
|
|||
|
|
return sorted(ips)
|
|||
|
|
|
|||
|
|
def _on_resolve_ips(self) -> None:
|
|||
|
|
if not self._last_bundle:
|
|||
|
|
messagebox.showinfo("IP-Ortung", "Zuerst Snapshot laden oder aktualisieren.")
|
|||
|
|
return
|
|||
|
|
ips = self._gather_ips_from_bundle()
|
|||
|
|
if not ips:
|
|||
|
|
messagebox.showinfo("IP-Ortung", "Keine IPv4-Adressen in Sessions/Geräten erkannt.")
|
|||
|
|
return
|
|||
|
|
if not self._ip_disclaimed:
|
|||
|
|
messagebox.showinfo(
|
|||
|
|
"Hinweis",
|
|||
|
|
"Ort über öffentliche IP ist ungenau und zeigt häufig nur Provider/Region/Stadt "
|
|||
|
|
"(keine Zuverlässigkeit für konkrete Adresse). Datenabfrage nutzt externes HTTP (ip-api.com). "
|
|||
|
|
"Nur wenn Sie diese Näherung bewusst wünschen.",
|
|||
|
|
)
|
|||
|
|
self._ip_disclaimed = True
|
|||
|
|
|
|||
|
|
self._queue.put(("toast", "Frage öffentliche IP-Näherungsdaten extern ab …"))
|
|||
|
|
|
|||
|
|
def run_geo() -> None:
|
|||
|
|
cmap: Dict[str, str] = {}
|
|||
|
|
for ipa in ips:
|
|||
|
|
cmap[ipa] = lookup_ip_geo_optional(ipa) or "nicht aufgelöst"
|
|||
|
|
self._ip_geo_cache.update(cmap)
|
|||
|
|
self._queue.put(("ip_done", None))
|
|||
|
|
|
|||
|
|
threading.Thread(target=run_geo, daemon=True).start()
|
|||
|
|
|
|||
|
|
def _on_refresh_remote(self) -> None:
|
|||
|
|
if not shutil_which("scp") or not shutil_which("ssh"):
|
|||
|
|
messagebox.showerror("OpenSSH benötigt", "Installieren oder PATH setzen.")
|
|||
|
|
return
|
|||
|
|
stamp = _now_local_stamp()
|
|||
|
|
snap_name = f"AZA_CONTROL_SNAPSHOT_{stamp}"
|
|||
|
|
try:
|
|||
|
|
self._snap_root.mkdir(parents=True, exist_ok=True)
|
|||
|
|
except Exception as exc:
|
|||
|
|
messagebox.showerror("Snapshot-Ordner", str(exc))
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
snap_dir = self._snap_root / snap_name
|
|||
|
|
rd_path = snap_dir / "raw_data_copy"
|
|||
|
|
|
|||
|
|
def worker() -> None:
|
|||
|
|
notes: List[str] = []
|
|||
|
|
try:
|
|||
|
|
self._queue.put(("toast", "Lese remote data (read-only) …"))
|
|||
|
|
rd_path.mkdir(parents=True, exist_ok=True)
|
|||
|
|
|
|||
|
|
remote_base = self._remote_data.rstrip("/")
|
|||
|
|
|
|||
|
|
r_esc = shlex.quote(remote_base.replace("\\", "/"))
|
|||
|
|
probe = subprocess.run(
|
|||
|
|
[
|
|||
|
|
"ssh",
|
|||
|
|
"-o",
|
|||
|
|
"BatchMode=yes",
|
|||
|
|
"-o",
|
|||
|
|
"ConnectTimeout=12",
|
|||
|
|
"-o",
|
|||
|
|
"StrictHostKeyChecking=accept-new",
|
|||
|
|
self._server_spec,
|
|||
|
|
"echo SSH_OK && test -d " + r_esc,
|
|||
|
|
],
|
|||
|
|
capture_output=True,
|
|||
|
|
text=True,
|
|||
|
|
encoding="utf-8",
|
|||
|
|
errors="replace",
|
|||
|
|
timeout=22,
|
|||
|
|
)
|
|||
|
|
outp = (probe.stdout or "") + (probe.stderr or "")
|
|||
|
|
if probe.returncode != 0:
|
|||
|
|
raise RuntimeError(outp[:2400])
|
|||
|
|
|
|||
|
|
for fname in COPY_BLOCK:
|
|||
|
|
remote_path = remote_base + "/" + fname
|
|||
|
|
lp = rd_path / fname
|
|||
|
|
pr = subprocess.run(
|
|||
|
|
[
|
|||
|
|
"scp",
|
|||
|
|
"-q",
|
|||
|
|
"-o",
|
|||
|
|
"BatchMode=yes",
|
|||
|
|
"-o",
|
|||
|
|
"ConnectTimeout=42",
|
|||
|
|
"-o",
|
|||
|
|
"StrictHostKeyChecking=accept-new",
|
|||
|
|
f"{self._server_spec}:{remote_path}",
|
|||
|
|
str(lp),
|
|||
|
|
],
|
|||
|
|
capture_output=True,
|
|||
|
|
timeout=560,
|
|||
|
|
text=True,
|
|||
|
|
encoding="utf-8",
|
|||
|
|
errors="replace",
|
|||
|
|
)
|
|||
|
|
req = fname in REMOTE_CORE_REQUIRED_POST_SCP
|
|||
|
|
if pr.returncode != 0:
|
|||
|
|
err = str(pr.stderr or pr.stdout or "")[:400]
|
|||
|
|
if req:
|
|||
|
|
raise RuntimeError(f"Kern-Datei {fname} konnte nicht kopiert werden: {err}")
|
|||
|
|
notes.append(fname + ": SCP optional fehlgeschlagen")
|
|||
|
|
try:
|
|||
|
|
if lp.exists() and lp.stat().st_size == 0:
|
|||
|
|
lp.unlink(missing_ok=True)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
for req in REMOTE_CORE_REQUIRED_POST_SCP:
|
|||
|
|
pth = rd_path / req
|
|||
|
|
if not pth.is_file():
|
|||
|
|
raise RuntimeError(f"Kern-Datei fehlt lokal nach SCP: {req}")
|
|||
|
|
|
|||
|
|
bundle_, _ = analyse_snapshot(rd_path, scp_optional_misses=sorted(set(notes)))
|
|||
|
|
export_snapshot_folder(
|
|||
|
|
snap_dir,
|
|||
|
|
bundle_,
|
|||
|
|
server_spec=self._server_spec,
|
|||
|
|
remote_path=self._remote_data,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
self._queue.put(("snap_done", (str(snap_dir), bundle_, sorted(set(notes)))))
|
|||
|
|
except Exception as exc:
|
|||
|
|
import traceback as tb_lib
|
|||
|
|
|
|||
|
|
self._queue.put(("snap_fail", str(exc) + "\n\n" + tb_lib.format_exc(limit=12)[-1500:]))
|
|||
|
|
|
|||
|
|
threading.Thread(target=worker, daemon=True).start()
|
|||
|
|
|
|||
|
|
def _on_open_manual(self) -> None:
|
|||
|
|
sel = filedialog.askdirectory(title="raw_data_copy wählen (oder Snapshot-übergeordnet)", parent=self)
|
|||
|
|
if not sel:
|
|||
|
|
return
|
|||
|
|
pth = Path(sel)
|
|||
|
|
rd = pth if pth.name == "raw_data_copy" else (pth / "raw_data_copy")
|
|||
|
|
if not rd.is_dir():
|
|||
|
|
rz = filedialog.askdirectory(title='Ordner „raw_data_copy“', initialdir=str(pth), parent=self)
|
|||
|
|
if not rz:
|
|||
|
|
return
|
|||
|
|
rd = Path(rz)
|
|||
|
|
if rd.name != "raw_data_copy":
|
|||
|
|
messagebox.showwarning("Pfad", "Es muss raw_data_copy heißen.")
|
|||
|
|
return
|
|||
|
|
try:
|
|||
|
|
bundle_local, warns = analyse_snapshot(rd)
|
|||
|
|
bundle_local["_meta"] = {"raw_copy_dir": str(rd.resolve())}
|
|||
|
|
self._current_snap = rd.parent
|
|||
|
|
self._last_bundle = bundle_local
|
|||
|
|
self._inject_geo(bundle_local)
|
|||
|
|
self._populate_all(bundle_local)
|
|||
|
|
self._last_snap_var.set(str(self._current_snap.resolve()))
|
|||
|
|
self._soft_notes_var.set("(lokal geöffnet)")
|
|||
|
|
self._ssh_ok.set("offline / ohne SCP")
|
|||
|
|
if warns:
|
|||
|
|
messagebox.showinfo("Hinweise Einlesen", "\n".join(warns[:24]) + ("\n…" if len(warns) > 24 else ""))
|
|||
|
|
except Exception as exc:
|
|||
|
|
messagebox.showerror("Öffnen", str(exc))
|
|||
|
|
|
|||
|
|
def _on_export_reports(self) -> None:
|
|||
|
|
if not self._last_bundle or not self._current_snap:
|
|||
|
|
messagebox.showinfo("Export", "Zuerst Snapshot laden oder aktualisieren.")
|
|||
|
|
return
|
|||
|
|
try:
|
|||
|
|
export_snapshot_folder(Path(self._current_snap), self._last_bundle, self._server_spec, self._remote_data)
|
|||
|
|
messagebox.showinfo("Export", f"Bereits gespeichert unter:\n{Path(self._current_snap).resolve()}")
|
|||
|
|
except Exception as exc:
|
|||
|
|
messagebox.showerror("Export-Fehler", str(exc))
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
AdminControlShell().mainloop()
|
|||
|
|
|