Files
aza/AzA march 2026/aza_workspace_license.py

406 lines
13 KiB
Python
Raw Normal View History

2026-05-06 22:43:22 +02:00
# -*- 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",
]