Files
aza/AzA march 2026/aza_workspace_license.py
2026-05-06 22:43:22 +02:00

406 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""Lizenzgebundener Workspace: Identität, Zustand, optionaler Import-Dialog.
Trennt klar:
- **Lizenz-Workspace** (Autotext-Kürzel/Texte, Textblöcke, workspace_backup_ts)
→ Cloud-Sync pro Lizenzschlüssel-Fingerprint (Supabase todo_sync Zeilen).
- **Demo ohne Lizenzmaterial** → kein Cloud-Workspace (nur lokal).
- **Praxis-/Empfang-/sonstige Autotext-Felder** bleiben unangetastet beim
Workspace-„Verwerfen“; es werden nur die lizenzgebundenen Schlüssel geleert.
Schlüsselmaterial (Priorität): ``license_key`` im Benutzerprofil, sonst
persistierter Aktivierungsschlüssel (`aza_activation`).
"""
from __future__ import annotations
import hashlib
import json
import os
import time
from datetime import datetime, timezone
from typing import Any, Dict, Optional, Tuple
from aza_config import get_writable_data_dir
from aza_activation import load_activation_key
_LICENSE_STATE_FILENAME = "workspace_license_state.json"
DEMO_WORKSPACE_TAG = "__demo__"
_WS_LICENSE_DIALOG_ACTIVE = False
_SNAPSHOT_SUBDIR = "workspace_license_snapshots"
def utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _state_path() -> str:
return os.path.join(get_writable_data_dir(), _LICENSE_STATE_FILENAME)
def load_workspace_license_state() -> Dict[str, Any]:
try:
p = _state_path()
if os.path.isfile(p):
with open(p, "r", encoding="utf-8") as f:
d = json.load(f)
if isinstance(d, dict):
return d
except Exception:
pass
return {}
def save_workspace_license_state(data: Dict[str, Any]) -> None:
try:
p = _state_path()
root = os.path.dirname(p)
if root:
os.makedirs(root, exist_ok=True)
tmp = p + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
os.replace(tmp, p)
except Exception:
pass
def resolve_license_material_raw() -> str:
"""Nicht-hash Rohmaterial für den Workspace-Fingerprint (leer = Demo)."""
try:
from aza_persistence import load_user_profile
lk = (load_user_profile().get("license_key") or "").strip()
if lk:
return lk
except Exception:
pass
try:
act = load_activation_key()
if act:
return act.strip()
except Exception:
pass
return ""
def resolve_workspace_identity_tag() -> str:
"""Stabiler Workspace-Identifikator für Sync und lokale Zuordnung.
64-Zeichen-SHA256-Hex über normalisiertes Lizenz-/Aktivierung-Material,
oder ``DEMO_WORKSPACE_TAG`` wenn keins vorliegt."""
raw = resolve_license_material_raw()
if not raw:
return DEMO_WORKSPACE_TAG
norm = raw.strip().upper()
if not norm:
return DEMO_WORKSPACE_TAG
return hashlib.sha256(norm.encode("utf-8")).hexdigest()
def workspace_cloud_row_ids(workspace_tag: str) -> Tuple[Optional[int], Optional[int]]:
"""Stabile Supabase todo_sync IDs für Autotext / Textblöcke dieser Lizenz.
Verwendet einen freien Zahlenbereich, abgeleitet vom Tag — keine Nutzung
der älteren globalen Fest-IDs 3/4 mehr (Vermischung verschiedener Lizenzen).
Bei Demo ohne Lizenz: ``(None, None)`` → kein Cloud-Workspace.
"""
if not workspace_tag or workspace_tag == DEMO_WORKSPACE_TAG:
return (None, None)
digest = hashlib.sha256(workspace_tag.encode("utf-8")).digest()
h = int.from_bytes(digest[:7], "big")
slot = h % 3_500_000
base = 20_000_000 + slot * 2
return int(base), int(base + 1)
def _entry_has_nonblank_text(entries: Dict[str, Any]) -> bool:
if not isinstance(entries, dict):
return False
for _k, v in entries.items():
if isinstance(v, str) and v.strip():
return True
if isinstance(v, dict) and str(v.get("text") or "").strip():
return True
return False
def local_workspace_has_meaningful_data() -> bool:
"""True, wenn lizenzrelevante lokale Daten sinnvoll befüllt sind."""
try:
from aza_persistence import load_autotext, load_textbloecke
at = load_autotext()
if _entry_has_nonblank_text(at.get("entries") if isinstance(at, dict) else {}):
return True
except Exception:
pass
try:
from aza_persistence import load_textbloecke
tb = load_textbloecke()
if isinstance(tb, dict):
for k, v in tb.items():
if not isinstance(k, str) or not isinstance(v, dict):
continue
if str(v.get("content") or "").strip():
return True
name_g = str(v.get("name") or "").strip()
default_nm = f"Textblock {k}"
if name_g and name_g != default_nm:
return True
except Exception:
pass
return False
def _snapshot_dir() -> str:
base = os.path.join(get_writable_data_dir(), _SNAPSHOT_SUBDIR)
os.makedirs(base, exist_ok=True)
return base
def backup_local_workspace_before_discard() -> Optional[str]:
"""Schreibt JSON-Snapshot vor Verwerfen; Rückgabe Pfad oder None."""
try:
from aza_persistence import load_autotext, load_textbloecke
at = load_autotext()
tb = load_textbloecke()
blob = {
"v": 1,
"ts": time.time(),
"stored_tag_before": load_workspace_license_state().get(
"stored_workspace_identity_tag", DEMO_WORKSPACE_TAG),
"resolved_tag_now": resolve_workspace_identity_tag(),
"autotext_entries": dict((at.get("entries") or {}))
if isinstance(at, dict) else {},
"autotext_entry_meta": dict((at.get("entry_meta") or {}))
if isinstance(at.get("entry_meta"), dict) else {},
"textbloecke": dict(tb or {}),
}
fname = os.path.join(
_snapshot_dir(),
f"pre_discard_workspace_{int(time.time())}.json",
)
with open(fname, "w", encoding="utf-8") as f:
json.dump(blob, f, ensure_ascii=False, indent=2)
return fname
except Exception:
return None
def clear_local_license_workspace_disk_only() -> None:
"""Leert nur lizenzgebundenen Workspace auf der Platte; Praxis-/UI-Felder bleiben."""
try:
from aza_persistence import load_autotext, save_autotext
data = dict(load_autotext())
data["entries"] = {}
data["entry_meta"] = {}
data["workspace_backup_ts"] = ""
save_autotext(data)
except Exception:
pass
try:
from aza_persistence import save_textbloecke
empty_slots = {
"1": {
"name": "Textblock 1",
"content": "",
"updated_at": utc_now_iso(),
},
"2": {
"name": "Textblock 2",
"content": "",
"updated_at": utc_now_iso(),
},
}
save_textbloecke(empty_slots)
except Exception:
pass
def _apply_cleared_workspace_to_app_runtime(app: Any) -> None:
try:
tgt = getattr(app, "_autotext_data", None)
if isinstance(tgt, dict):
entries = tgt.get("entries")
if isinstance(entries, dict):
entries.clear()
else:
tgt["entries"] = {}
meta = tgt.get("entry_meta")
if isinstance(meta, dict):
meta.clear()
else:
tgt["entry_meta"] = {}
tgt["workspace_backup_ts"] = ""
from aza_persistence import save_autotext
save_autotext(tgt)
shell = getattr(app, "_aza_office_v1", None)
if shell is not None and hasattr(shell, "refresh_sidebar_textbloecke_section"):
shell.refresh_sidebar_textbloecke_section()
except Exception:
pass
def reconcile_stored_workspace_identity_without_dialog() -> bool:
"""Wenn Wechsel ohne relevante lokale Daten: ``stored`` anheben.
Gibt ``True`` zurück, wenn Hybrid-Sync sofort ohne Dialog starten soll,
``False`` wenn weiterhin ein mismatch mit relevanten Daten besteht."""
cur = resolve_workspace_identity_tag()
st = dict(load_workspace_license_state())
stored = str(st.get("stored_workspace_identity_tag", DEMO_WORKSPACE_TAG))
if cur == DEMO_WORKSPACE_TAG and stored != DEMO_WORKSPACE_TAG:
st["stored_workspace_identity_tag"] = DEMO_WORKSPACE_TAG
save_workspace_license_state(st)
return True
if cur == stored:
return True
if not local_workspace_has_meaningful_data():
st["stored_workspace_identity_tag"] = cur
save_workspace_license_state(st)
return True
return False
def defer_workspace_initial_hybrid_sync_for_license_dialog() -> bool:
"""True = vor erstem Hybrid-Sync den Import-Dialog zeigen."""
return not reconcile_stored_workspace_identity_without_dialog()
def _persist_stored_tag(tag: str) -> None:
st = dict(load_workspace_license_state())
st["stored_workspace_identity_tag"] = tag
save_workspace_license_state(st)
def _run_workspace_license_dialog_then_sync(app: Any) -> None:
global _WS_LICENSE_DIALOG_ACTIVE
try:
if reconcile_stored_workspace_identity_without_dialog():
from aza_workspace_sync import start_background_workspace_hybrid_sync
start_background_workspace_hybrid_sync(app)
return
cur = resolve_workspace_identity_tag()
stored = str(
load_workspace_license_state().get(
"stored_workspace_identity_tag", DEMO_WORKSPACE_TAG))
if cur == stored:
from aza_workspace_sync import start_background_workspace_hybrid_sync
start_background_workspace_hybrid_sync(app)
return
if _WS_LICENSE_DIALOG_ACTIVE:
try:
app.after(800, lambda: ensure_workspace_license_dialog_then_start_hybrid_sync(app))
except Exception:
pass
return
_WS_LICENSE_DIALOG_ACTIVE = True
try:
from tkinter import messagebox
parent = getattr(app, "tk", None) or app
txt = (
"Es wurden bestehende lokale AzA-Workspace-Daten gefunden "
"(Autotext-Kürzel und Textblöcke).\n\n"
"Das passt möglicherweise zu einer anderen oder älteren "
"Lizenzzuordnung. Diese Daten sollen nicht still mit einer "
"anderen Lizenz vermischt werden.\n\n"
"**Ja** — Lokale Daten in den Workspace dieser aktuellen "
"Lizenz übernehmen (mit Cloud-Zusammenführung sofern online).\n\n"
"**Nein** — Lokale Autotexte/Textblöcke verwerfen; vorher wird "
"ein Backup geschrieben. Anschließend gilt der Workspace der "
"aktuellen Lizenz (ggf. leer oder aus der Cloud)."
)
yn = messagebox.askyesno(
"AzA Workspace / Lizenz",
txt.replace("**", ""),
parent=parent,
default=messagebox.YES,
)
finally:
_WS_LICENSE_DIALOG_ACTIVE = False
if yn:
_persist_stored_tag(cur)
else:
backup_local_workspace_before_discard()
clear_local_license_workspace_disk_only()
_apply_cleared_workspace_to_app_runtime(app)
_persist_stored_tag(cur)
from aza_workspace_sync import start_background_workspace_hybrid_sync
start_background_workspace_hybrid_sync(app)
except Exception:
try:
from aza_workspace_sync import start_background_workspace_hybrid_sync
start_background_workspace_hybrid_sync(app)
except Exception:
pass
def _run_deferred_workspace_license_flow(app: Any) -> None:
try:
setattr(app, "_workspace_license_defer_after_id", None)
except Exception:
pass
_run_workspace_license_dialog_then_sync(app)
def ensure_workspace_license_dialog_then_start_hybrid_sync(app: Any) -> None:
"""Startpunkt beim Office-Shell-Aufbau und nach Lizenzänderung."""
if defer_workspace_initial_hybrid_sync_for_license_dialog():
try:
old = getattr(app, "_workspace_license_defer_after_id", None)
if old is not None:
try:
app.after_cancel(old)
except Exception:
pass
app._workspace_license_defer_after_id = app.after(
450, lambda: _run_deferred_workspace_license_flow(app))
except Exception:
_run_deferred_workspace_license_flow(app)
return
try:
from aza_workspace_sync import start_background_workspace_hybrid_sync
start_background_workspace_hybrid_sync(app)
except Exception:
pass
__all__ = [
"DEMO_WORKSPACE_TAG",
"backup_local_workspace_before_discard",
"clear_local_license_workspace_disk_only",
"defer_workspace_initial_hybrid_sync_for_license_dialog",
"ensure_workspace_license_dialog_then_start_hybrid_sync",
"local_workspace_has_meaningful_data",
"load_workspace_license_state",
"reconcile_stored_workspace_identity_without_dialog",
"resolve_license_material_raw",
"resolve_workspace_identity_tag",
"save_workspace_license_state",
"workspace_cloud_row_ids",
]