Files
anonymisation/gui_v6/app.py
2026-06-30 10:44:02 +02:00

286 lines
10 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
"""Shell de la GUI V6 (G4 — alignement maquette).
Reproduit l'identité de ``docs/ui_mockup_v6.html`` : shell étroit, header avec
identité produit + version + statut licence + liseré accent, barre d'onglets
custom (pas CTkTabview brut), navigation par panneaux mis en cache après leur
première ouverture visible, changement de thème à chaud. La logique (runner
moteur, config, licence) est inchangée.
La fenêtre n'est créée qu'à l'instanciation de :class:`AnonymisationApp`.
"""
from __future__ import annotations
import os
from typing import Optional
import customtkinter as ctk
from gui_v6 import theme as theme_mod
from gui_v6 import ui_kit
from gui_v6.config_paths import resolve_user_config_path
from gui_v6.config_state import ConfigState
from gui_v6.license_client import LicenseClient, LicenseStatus
from gui_v6.machine_id import default_machine_id
from gui_v6.tabs.tab_about import AboutTab
from gui_v6.tabs.tab_config import ConfigTab
from gui_v6.tabs.tab_usage import UsageTab
DEFAULT_PORTAL_URL = "https://app.aivanov.eu"
def resolve_portal_url() -> str:
"""URL du portail : env ``ANON_PORTAL_URL`` sinon défaut prod."""
return os.environ.get("ANON_PORTAL_URL", DEFAULT_PORTAL_URL)
def bound_local_status(status: LicenseStatus, local_machine_id: str) -> LicenseStatus:
"""Annoter le statut licence selon le binding poste.
Souple (décision D1) : on N'EMPÊCHE PAS le traitement. Si la licence locale
est valide mais liée à un autre ``machine_id`` que le poste courant (ex.
``license.json`` copié), on le **signale** par un statut non valide d'affichage.
"""
if status.valid and status.machine_id and status.machine_id != local_machine_id:
return LicenseStatus(
valid=False,
status="autre_poste",
message="Licence liée à un autre poste",
expires_at=status.expires_at,
grace_days=status.grace_days,
machine_id=status.machine_id,
license_ref=status.license_ref,
)
return status
_TABS = [
("use", "📄 Utilisation"),
("cfg", "⚙️ Administration"),
("about", " À propos"),
]
class AnonymisationApp(ctk.CTk):
def __init__(
self,
license_client: Optional[LicenseClient] = None,
theme_name: str = theme_mod.DEFAULT_THEME,
) -> None:
super().__init__()
self._theme_name = theme_name
self._license_client = license_client or LicenseClient(resolve_portal_url())
self._config = ConfigState()
self._user_config_path = resolve_user_config_path()
self._active = "use"
self._tab_buttons: dict = {}
self._tab_frames: dict = {}
self._visible_tab = None
self.title("Pseudonymisation de vos documents — bêta")
self.geometry("820x880")
self.minsize(720, 680)
self._render()
# -- thème / rendu ----------------------------------------------------
def set_theme(self, name: str) -> None:
self._theme_name = name
self._render()
def _render(self) -> None:
self._palette = theme_mod.apply_theme(self._theme_name)
p = self._palette
try:
self.configure(fg_color=p["bg"])
except Exception:
pass
for child in self.winfo_children():
child.destroy()
# Les frames d'onglets mis en cache étaient des enfants détruits ci-dessus :
# on vide le cache pour que ``_show`` recrée proprement l'onglet actif
# (sinon on re-packe un widget mort → onglet vide / TclError au changement de thème).
self._tab_frames = {}
self._visible_tab = None
self._build_header(p)
self._build_tabsbar(p)
self._content = ctk.CTkScrollableFrame(self, fg_color=p["bg"])
self._content.pack(fill="both", expand=True)
self._show(self._active)
# -- header -----------------------------------------------------------
def _safe_local_status(self) -> LicenseStatus:
try:
status = self._license_client.local_status()
return bound_local_status(status, default_machine_id())
except Exception:
return LicenseStatus.unavailable()
def _build_header(self, p: dict) -> None:
header = ctk.CTkFrame(self, fg_color=p["card"], corner_radius=0)
header.pack(fill="x")
identity = ctk.CTkFrame(header, fg_color="transparent")
identity.pack(side="left", padx=16, pady=10)
ctk.CTkLabel(
identity, text="🛡️ aivanonym", text_color=p["text"], font=ui_kit.font(18, "bold")
).pack(side="left")
ctk.CTkLabel(
identity,
text="bêta",
text_color="#ffffff",
fg_color=p["primary"],
corner_radius=8,
font=ui_kit.font(10, "bold"),
).pack(side="left", padx=(8, 0), ipadx=6, ipady=1)
status = self._safe_local_status()
ctk.CTkLabel(
header,
text=f"licence : {status.status}",
text_color=theme_mod.status_color(self._theme_name, status.status),
font=ui_kit.font(11),
).pack(side="right", padx=(8, 16))
ctk.CTkLabel(
header, text="v6.0", text_color=p["text_muted"], font=ui_kit.font(11)
).pack(side="right", padx=4)
# Liseré accent sous le header (border-bottom 3px primary).
ctk.CTkFrame(self, fg_color=p["primary"], height=3, corner_radius=0).pack(fill="x")
# -- barre d'onglets --------------------------------------------------
def _build_tabsbar(self, p: dict) -> None:
bar = ctk.CTkFrame(self, fg_color=p["card"], corner_radius=0)
bar.pack(fill="x")
self._tab_buttons = {}
for key, label in _TABS:
active = key == self._active
btn = ctk.CTkButton(
bar,
text=label,
command=lambda k=key: self._show(k),
fg_color="transparent",
hover_color=p["card_border"],
text_color=p["primary"] if active else p["text_dim"],
font=ui_kit.font(13, "bold" if active else "normal"),
corner_radius=0,
width=10,
)
btn.pack(side="left", padx=4, pady=4)
self._tab_buttons[key] = btn
def _refresh_tabbar(self) -> None:
p = self._palette
for key, btn in self._tab_buttons.items():
active = key == self._active
btn.configure(
text_color=p["primary"] if active else p["text_dim"],
font=ui_kit.font(13, "bold" if active else "normal"),
)
# -- contenu ----------------------------------------------------------
def _create_tab(self, key: str):
p = self._palette
status = self._safe_local_status()
if key == "use":
return UsageTab(
self._content,
palette=p,
config_provider=lambda: self._config,
config_path=self._user_config_path,
on_theme_change=self.set_theme,
current_theme=self._theme_name,
usage_reporter=self._report_usage,
diag_reporter=self._report_diagnostics,
)
if key == "cfg":
return ConfigTab(self._content, palette=p, state=self._config, config_path=self._user_config_path)
return AboutTab(
self._content,
palette=p,
status=status,
theme_name=self._theme_name,
license_client=self._license_client,
)
# -- télémétrie d'usage -----------------------------------------------
def _usage_session(self):
if getattr(self, "_usage_http_session", None) is None:
try:
import requests
self._usage_http_session = requests.Session()
except Exception:
self._usage_http_session = None
return self._usage_http_session
def _report_usage(self, summary) -> None:
"""Envoie la télémétrie d'usage en fin de run (non bloquant, best-effort).
N'envoie rien si aucune licence locale valide. Ne lève jamais.
"""
try:
from gui_v6 import __version__ as gui_version
from gui_v6 import usage_telemetry
from gui_v6.machine_id import default_machine_id
session = self._usage_session()
if session is None:
return
status = self._safe_local_status()
base_url = getattr(self._license_client, "_base_url", "") or resolve_portal_url()
usage_telemetry.report_run_summary(
summary,
base_url=base_url,
license_ref=getattr(status, "license_ref", None),
machine_id=default_machine_id(),
session=session,
app_name="gui_v6",
app_version=gui_version,
)
except Exception:
pass
def _report_diagnostics(self, summary) -> None:
"""Envoie les diagnostics en fin de run (non bloquant, best-effort).
N'envoie rien si aucune licence locale valide. Ne lève jamais.
"""
try:
from gui_v6 import __version__ as gui_version
from gui_v6 import diagnostics
from gui_v6.logging_setup import log_file_path
from gui_v6.machine_id import default_machine_id
session = self._usage_session()
if session is None:
return
status = self._safe_local_status()
base_url = getattr(self._license_client, "_base_url", "") or resolve_portal_url()
spool = log_file_path().parent / "diagnostics_spool.jsonl"
diagnostics.report_run_diagnostics(
summary,
base_url=base_url,
license_ref=getattr(status, "license_ref", None),
machine_id=default_machine_id(),
session=session,
app_name="gui_v6",
app_version=gui_version,
spool_path=spool,
)
except Exception:
pass
def _show(self, key: str) -> None:
self._active = key
self._refresh_tabbar()
if self._visible_tab is not None:
self._tab_frames[self._visible_tab].pack_forget()
if key not in self._tab_frames:
self._tab_frames[key] = self._create_tab(key)
self._tab_frames[key].pack(fill="both", expand=True)
self._visible_tab = key