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

View File

@@ -338,10 +338,27 @@ def _jsonl_erstes_event_utc(path: Path, needle: str, max_lines: int = 48000) ->
def _tree_column_heading(col: str) -> str:
"""Kurze Überschriften; Fallback wie bisher."""
pretty = {
"license_key_display": "lizenz anzeige",
"license_key_display": "Lizenz",
"user_display_name": "Display Name",
"user_login_name": "Login Name",
"user_email": "E-Mail",
"user_id_short": "User-ID",
"user_assignment_status": "Zuordnung",
"user_assignment_source": "Quelle",
"device_count_registered": "Geräte",
"device_limit": "Geräte-Limit",
"office_device_limit": "Office-Limit",
"office_devices_used": "Office genutzt",
"chat_device_limit": "Chat-Limit",
"chat_devices_used": "Chat genutzt",
"contributing_office_licenses": "Office-Lizenzen",
"lookup_key": "Plan",
"last_activity": "Letzte Aktivität",
"stripe_letzte_db_aenderung_utc": "letzte stripe-db änderung",
"erstes_passendes_stripe_log": "erstes log-event (abo)",
"billing_or_customer_snippet": "rechnung/kunde (stichwort)",
"customer_email_license": "Stripe-E-Mail",
"stripe_customer_id": "Stripe-Kunde",
}
if col in pretty:
return pretty[col]
@@ -511,6 +528,223 @@ def _build_license_display_rows(stripe_sql: Path, stripe_jsonl: Path, practices:
return out_rows
def _accounts_index_for_license_match(
accounts: Dict[str, dict],
) -> Tuple[Dict[str, List[dict]], Dict[str, List[dict]]]:
by_key: Dict[str, List[dict]] = defaultdict(list)
by_email: Dict[str, List[dict]] = defaultdict(list)
for uid, ac in accounts.items():
if not isinstance(ac, dict):
continue
entry = dict(ac)
entry["_uid"] = uid
lk = str(ac.get("license_key") or ac.get("activation_key") or "").strip().upper()
if lk:
by_key[lk].append(entry)
em = str(ac.get("email") or "").strip().lower()
if em:
by_email[em].append(entry)
return by_key, by_email
def _count_devices(
devices_dict: Dict[str, dict],
*,
practice_id: str = "",
user_id: str = "",
) -> int:
n = 0
for d in devices_dict.values():
if not isinstance(d, dict):
continue
if practice_id and str(d.get("practice_id") or "").strip() != practice_id:
continue
if user_id and str(d.get("user_id") or "").strip() != user_id:
continue
n += 1
return n
def _device_last_activity(devices_dict: Dict[str, dict], user_id: str) -> str:
user_id = (user_id or "").strip()
if not user_id:
return ""
best_ts: Optional[int] = None
best_disp = ""
for d in devices_dict.values():
if not isinstance(d, dict):
continue
if str(d.get("user_id") or "").strip() != user_id:
continue
raw = _first_str(d, ("last_active", "last_seen", "updated_at"))
ts = _parse_ts_maybe(raw)
if ts is None:
continue
if best_ts is None or ts > best_ts:
best_ts = ts
best_disp = _ts_iso_or_raw(raw)
return best_disp
def _resolve_account_display_name(ac: Mapping[str, Any]) -> str:
"""Display Name aus Konto-Snapshot (empfang_accounts.json), ohne Erfindungen."""
dn = _first_str(ac, ("display_name",))
if dn:
return dn
nm = _first_str(ac, ("name",))
if nm:
return nm
fn = _first_str(ac, ("first_name", "firstname", "vorname"))
ln = _first_str(ac, ("last_name", "lastname", "nachname", "surname"))
if fn and ln:
return f"{fn} {ln}".strip()
if fn:
return fn
if ln:
return ln
em = _first_str(ac, ("email",))
if em and "@" in em:
return em.split("@", 1)[0]
return ""
def _resolve_account_login_name(ac: Mapping[str, Any]) -> str:
"""Login Name aus Konto-Snapshot; login_name ist das primäre Feld."""
for key in ("login_name", "username", "account_name"):
v = _first_str(ac, (key,))
if v:
return v
em = _first_str(ac, ("email",))
if em:
return em
uid = str(ac.get("user_id") or ac.get("_uid") or "").strip()
return uid
def _join_matched_field(matched: Sequence[dict], resolver) -> str:
vals = sorted({resolver(m) for m in matched if resolver(m)})
if not vals:
return ""
joined = "; ".join(vals[:4])
if len(vals) > 4:
joined += ""
return joined
def _enrich_license_rows_with_users(
license_rows: List[Dict[str, Any]],
accounts: Dict[str, dict],
devices_dict: Dict[str, dict],
) -> List[Dict[str, Any]]:
"""Read-only: ordnet Lizenzen Benutzern zu (license_key / E-Mail / Praxis)."""
by_key, by_email = _accounts_index_for_license_match(accounts)
out: List[Dict[str, Any]] = []
for row in license_rows:
rr = dict(row)
lk = str(rr.get("license_key_plain") or "").strip().upper()
pid = str(rr.get("practice_id") or "").strip()
cust_em = str(rr.get("customer_email_license") or "").strip().lower()
matched: List[dict] = []
source = ""
if lk:
keyed = by_key.get(lk, [])
if keyed:
matched = keyed
source = "license_key"
if not matched and cust_em and pid:
cands = [
a for a in by_email.get(cust_em, [])
if str(a.get("practice_id") or "").strip() == pid
]
if cands:
matched = cands
source = "email+practice_id"
if not matched and cust_em:
cands = by_email.get(cust_em, [])
if len(cands) == 1:
matched = cands
source = "email_eindeutig"
primary_uid = ""
if not matched:
rr["user_display_name"] = ""
rr["user_login_name"] = ""
rr["user_email"] = ""
rr["user_id_short"] = ""
rr["user_assignment_status"] = "Kein Benutzer zugeordnet"
rr["user_assignment_source"] = ""
elif len(matched) > 1:
rr["user_display_name"] = _join_matched_field(matched, _resolve_account_display_name)
rr["user_login_name"] = _join_matched_field(matched, _resolve_account_login_name)
emails = sorted({str(m.get("email") or "") for m in matched if str(m.get("email") or "").strip()})
rr["user_email"] = "; ".join(emails[:3]) or ""
uids = [
_short(str(m.get("user_id") or m.get("_uid") or ""), 12)
for m in matched[:4]
]
rr["user_id_short"] = "; ".join(uids)
rr["user_assignment_status"] = "Mehrdeutige Zuordnung"
rr["user_assignment_source"] = source or "mehrfach"
else:
m = matched[0]
primary_uid = str(m.get("user_id") or m.get("_uid") or "").strip()
rr["user_display_name"] = _resolve_account_display_name(m) or ""
rr["user_login_name"] = _resolve_account_login_name(m) or ""
rr["user_email"] = str(m.get("email") or "").strip() or ""
rr["user_id_short"] = _short(primary_uid, 16) if primary_uid else ""
rr["user_assignment_status"] = "OK"
rr["user_assignment_source"] = source
dev_lim = str(rr.get("devices_per_user") or "").strip() or ""
rr["device_limit"] = dev_lim
if primary_uid:
dev_n = _count_devices(devices_dict, user_id=primary_uid)
if not dev_n and pid:
dev_n = _count_devices(devices_dict, practice_id=pid)
elif pid:
dev_n = _count_devices(devices_dict, practice_id=pid)
else:
dev_n = 0
rr["device_count_registered"] = str(dev_n) if dev_n else "0"
try:
from aza_chat_device_capacity import (
DEVICE_SCOPE_OFFICE,
count_devices_by_scope,
practice_chat_capacity_snapshot,
)
cap = practice_chat_capacity_snapshot(license_rows, devices_dict, pid)
rr["office_device_limit"] = dev_lim
rr["office_devices_used"] = str(
count_devices_by_scope(devices_dict, practice_id=pid, scope=DEVICE_SCOPE_OFFICE)
)
rr["chat_device_limit"] = str(cap.get("chat_device_limit", 0))
rr["chat_devices_used"] = str(cap.get("chat_devices_used", 0))
rr["contributing_office_licenses"] = str(cap.get("contributing_office_licenses", 0))
rr["chat_devices_per_license"] = str(cap.get("chat_devices_per_license", 5))
except Exception:
rr["office_device_limit"] = dev_lim
rr["office_devices_used"] = rr["device_count_registered"]
rr["chat_device_limit"] = ""
rr["chat_devices_used"] = ""
rr["contributing_office_licenses"] = ""
rr["chat_devices_per_license"] = "5"
last_act = _device_last_activity(devices_dict, primary_uid) if primary_uid else ""
if not last_act:
last_act = str(rr.get("stripe_letzte_db_aenderung_utc") or "").strip()
rr["last_activity"] = last_act or ""
out.append(rr)
return out
def _session_device_ip(s: Mapping[str, Any]) -> str:
return _first_str(
s,
@@ -747,6 +981,7 @@ def analyse_snapshot(
by_practice[pid].append(a)
license_rows_ui = _build_license_display_rows(paths["stripe_sql"], paths["stripe_log"], practices, accounts)
license_rows_ui = _enrich_license_rows_with_users(license_rows_ui, accounts, devices_dict)
lic_customer_by_pid = _merge_license_customer_email_per_practice(license_rows_ui)
dup_names = defaultdict(list)
@@ -1509,20 +1744,23 @@ def export_snapshot_folder(snap_dir: Path, bundle: Mapping[str, Any], server_spe
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 = [
"status",
"license_key_masked",
"license_suffix",
"license_sha256",
"customer_email_license",
"user_display_name",
"user_login_name",
"user_email",
"user_id_short",
"user_assignment_status",
"practice_name",
"practice_id",
"subscription_id",
"woo_order_id",
"device_count_registered",
"device_limit",
"lookup_key",
"status",
"current_period_end",
"stripe_letzte_db_aenderung_utc",
"erstes_passendes_stripe_log",
"billing_or_customer_snippet",
"last_activity",
"subscription_id",
"stripe_customer_id",
"customer_email_license",
"user_assignment_source",
"sources",
]
lic_html_rows = []
@@ -1660,8 +1898,8 @@ class AdminControlShell(tk.Tk):
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")
text="Quellen: stripe_webhook.sqlite, empfang_accounts.json, empfang_devices.json. "
"Benutzer-Zuordnung read-only über Lizenzschlüssel / E-Mail / Praxis — keine Secrets.").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")
@@ -1669,7 +1907,14 @@ class AdminControlShell(tk.Tk):
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)
self._txt_license_detail = scrolledtext.ScrolledText(
lf_body, height=7, wrap="word", font=("Consolas", 9), bg="#fdfefe", fg="#1a3550",
)
self._txt_license_detail.grid(row=1, column=0, columnspan=2, sticky="nsew", pady=(4, 0))
self._txt_license_detail.configure(state="disabled")
self._tv_license.bind("<<TreeviewSelect>>", self._on_license_select)
lf_body.grid_rowconfigure(0, weight=3)
lf_body.grid_rowconfigure(1, weight=1)
lf_body.grid_columnconfigure(0, weight=1)
self._tree_links = self._mk_plain_tree(self._nb, "Codes / Verbindungen")
@@ -2023,22 +2268,28 @@ class AdminControlShell(tk.Tk):
def _populate_licenses(self, bundle: Mapping[str, Any]) -> None:
cols = [
"status",
"license_key_display",
"license_suffix",
"license_sha256",
"customer_email_license",
"user_display_name",
"user_login_name",
"user_email",
"user_id_short",
"user_assignment_status",
"practice_name",
"practice_id",
"subscription_id",
"woo_order_id",
"stripe_customer_id",
"device_count_registered",
"office_device_limit",
"office_devices_used",
"chat_device_limit",
"chat_devices_used",
"contributing_office_licenses",
"device_limit",
"lookup_key",
"status",
"current_period_end",
"stripe_letzte_db_aenderung_utc",
"erstes_passendes_stripe_log",
"billing_or_customer_snippet",
"sources",
"last_activity",
"subscription_id",
"stripe_customer_id",
"customer_email_license",
"user_assignment_source",
]
rows: List[Dict[str, Any]] = []
for rr in bundle.get("license_rows", []) or []:
@@ -2048,6 +2299,54 @@ class AdminControlShell(tk.Tk):
row["license_key_display"] = self._license_cell(row)
rows.append(row)
self._clear_fill(self._tv_license, cols, rows)
self._license_rows_cache = rows
self._set_text_widget(self._txt_license_detail, "Zeile auswählen für Lizenz-/Benutzer-Details (read-only).")
def _on_license_select(self, _evt=None) -> None:
sel = self._tv_license.selection()
if not sel:
return
try:
idx = self._tv_license.index(sel[0])
except Exception:
return
rows = getattr(self, "_license_rows_cache", None) or []
if idx < 0 or idx >= len(rows):
return
r = rows[idx]
lk_show = self._license_cell(r)
lines = [
"=== Lizenz-Detail (read-only) ===",
f"Status: {r.get('status', '')}",
f"Lizenz: {lk_show}",
f"Plan (lookup_key): {r.get('lookup_key', '')}",
f"Subscription: {r.get('subscription_id', '')}",
f"Stripe-Kunde: {r.get('stripe_customer_id', '')}",
f"Stripe-E-Mail: {r.get('customer_email_license', '')}",
"",
"=== Benutzer ===",
f"Display Name: {r.get('user_display_name', '')}",
f"Login Name: {r.get('user_login_name', '')}",
f"E-Mail: {r.get('user_email', '')}",
f"User-ID: {r.get('user_id_short', '')}",
f"Zuordnung: {r.get('user_assignment_status', '')}",
f"Quelle: {r.get('user_assignment_source', '')}",
"",
"=== Praxis / Geräte ===",
f"Praxis: {r.get('practice_name', '')} ({r.get('practice_id', '')})",
f"Office-Geräte-Limit (Lizenz): {r.get('office_device_limit', r.get('device_limit', ''))}",
f"Office-Geräte genutzt: {r.get('office_devices_used', '')}",
f"Chat-Geräte-Limit (Praxis): {r.get('chat_device_limit', '')} "
f"(+{r.get('chat_devices_per_license', '5')} pro Office-Lizenz)",
f"Chat-Geräte genutzt: {r.get('chat_devices_used', '')}",
f"Beitragende Office-Lizenzen: {r.get('contributing_office_licenses', '')}",
f"Geräte registriert (gesamt): {r.get('device_count_registered', '')}",
f"Geräte-Limit (Stripe): {r.get('device_limit', '')}",
f"Letzte Aktivität: {r.get('last_activity', '')}",
f"DB-Stand: {r.get('stripe_letzte_db_aenderung_utc', '')}",
f"Datenquelle: {r.get('sources', '')}",
]
self._set_text_widget(self._txt_license_detail, "\n".join(lines))
def _populate_sessions_devices(self, bundle: Mapping[str, Any]) -> None:
scols = [