From 118f01f81aff84b3678980f567e55760e1df399e Mon Sep 17 00:00:00 2001 From: allebonvi Date: Wed, 1 Jul 2026 08:59:06 +0200 Subject: [PATCH] Aggiunge GUI di configurazione --- README.md | 9 + SPECIFICHE.md | 7 +- pyproject.toml | 2 + src/bakrest/config.py | 16 +- src/bakrest/config_app.py | 343 ++++++++++++++++++++++++++++++++++++++ src/bakrest/registry.py | 3 + tests/test_config_save.py | 14 ++ tests/test_registry.py | 26 +++ 8 files changed, 418 insertions(+), 2 deletions(-) create mode 100644 src/bakrest/config_app.py create mode 100644 tests/test_config_save.py create mode 100644 tests/test_registry.py diff --git a/README.md b/README.md index ed89189..2cf4f66 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,15 @@ python -m bakrest.tray_app La tray app legge lo stato prodotto dal servizio e offre il comando `Backup e spegni`. Se il backup fallisce, lo spegnimento non viene eseguito. +## Avvio GUI configurazione + +```powershell +python -m pip install -e . +python -m bakrest.config_app +``` + +La GUI permette di modificare la configurazione e vedere la coda dei file in attesa di backup. I file in coda restano persistenti anche se il computer viene spento senza eseguire il backup. + ## Installazione servizio Windows Da PowerShell avviato come amministratore: diff --git a/SPECIFICHE.md b/SPECIFICHE.md index e8c1199..3d3fad4 100644 --- a/SPECIFICHE.md +++ b/SPECIFICHE.md @@ -72,7 +72,12 @@ La GUI di configurazione deve permettere di: - configurare il comando o percorso di `rsync`; - visualizzare lo stato corrente; - aprire i log giornalieri; -- eventualmente eseguire un test di raggiungibilita' del server. +- eventualmente eseguire un test di raggiungibilita' del server; +- visualizzare l'elenco dei file in coda per il backup; +- rimuovere singoli file dalla coda; +- proporre, quando si rimuove un file dalla coda, se rimuovere solo il file o escludere dal backup la cartella che lo contiene. + +Se un computer viene spento senza usare `Backup e spegni`, i file gia' registrati come `pending` devono restare nel registro cambiamenti e accumularsi con quelli del giorno successivo, in modo da poter essere sincronizzati al backup successivo. ## Backup diff --git a/pyproject.toml b/pyproject.toml index c92cede..255827f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,8 +5,10 @@ description = "Windows watchdog service and tray backup utility." readme = "README.md" requires-python = ">=3.11" dependencies = [ + "customtkinter>=5.2.2", "pillow>=10.0.0", "pystray>=0.19.5", + "tomli-w>=1.0.0", "watchdog>=4.0.0", "pywin32>=306; platform_system == 'Windows'", ] diff --git a/src/bakrest/config.py b/src/bakrest/config.py index 73c647d..ca9e85f 100644 --- a/src/bakrest/config.py +++ b/src/bakrest/config.py @@ -6,6 +6,8 @@ from dataclasses import dataclass from pathlib import Path from typing import Any +import tomli_w + from .default_config import DEFAULT_CONFIG_TOML from .paths import default_config_path, expand_path @@ -78,10 +80,22 @@ def copy_example_config(destination: Path) -> None: def load_config(path: Path | None = None) -> AppConfig: config_path = ensure_default_config(path) - data = tomllib.loads(config_path.read_text(encoding="utf-8")) + data = load_config_data(config_path) return parse_config(data, config_path) +def load_config_data(path: Path | None = None) -> dict[str, Any]: + config_path = ensure_default_config(path) + return tomllib.loads(config_path.read_text(encoding="utf-8")) + + +def save_config_data(data: dict[str, Any], path: Path | None = None) -> Path: + config_path = path or default_config_path() + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(tomli_w.dumps(data), encoding="utf-8") + return config_path + + def parse_config(data: dict[str, Any], path: Path) -> AppConfig: service = data.get("service", {}) tray = data.get("tray", {}) diff --git a/src/bakrest/config_app.py b/src/bakrest/config_app.py new file mode 100644 index 0000000..51a60a0 --- /dev/null +++ b/src/bakrest/config_app.py @@ -0,0 +1,343 @@ +from __future__ import annotations + +import tkinter as tk +from pathlib import Path +from tkinter import filedialog, messagebox + +import customtkinter as ctk + +from .config import load_config, load_config_data, save_config_data +from .paths import default_config_path +from .registry import ChangeEvent, ChangeRegistry + + +class BakRestConfigApp(ctk.CTk): + def __init__(self) -> None: + super().__init__() + ctk.set_appearance_mode("system") + ctk.set_default_color_theme("blue") + + self.title("Bak&Rest Config") + self.geometry("980x680") + self.minsize(860, 560) + + self.config_path = default_config_path() + self.data = load_config_data(self.config_path) + self.app_config = load_config(self.config_path) + self.registry = ChangeRegistry(self.app_config.storage.change_registry) + self.pending_events: list[ChangeEvent] = [] + + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(1, weight=1) + + self.header = ctk.CTkFrame(self, corner_radius=0) + self.header.grid(row=0, column=0, sticky="ew") + self.header.grid_columnconfigure(0, weight=1) + + self.path_label = ctk.CTkLabel( + self.header, + text=f"Configurazione: {self.config_path}", + anchor="w", + ) + self.path_label.grid(row=0, column=0, sticky="ew", padx=16, pady=10) + + self.tabs = ctk.CTkTabview(self) + self.tabs.grid(row=1, column=0, sticky="nsew", padx=16, pady=(8, 16)) + self.general_tab = self.tabs.add("Configurazione") + self.queue_tab = self.tabs.add("Coda backup") + + self._build_general_tab() + self._build_queue_tab() + self._load_values() + self._refresh_queue() + + def _build_general_tab(self) -> None: + self.general_tab.grid_columnconfigure(0, weight=1) + self.general_tab.grid_columnconfigure(1, weight=1) + self.general_tab.grid_rowconfigure(0, weight=1) + + left = ctk.CTkFrame(self.general_tab) + left.grid(row=0, column=0, sticky="nsew", padx=(0, 8), pady=8) + left.grid_columnconfigure(0, weight=1) + left.grid_rowconfigure(1, weight=1) + left.grid_rowconfigure(3, weight=1) + + right = ctk.CTkFrame(self.general_tab) + right.grid(row=0, column=1, sticky="nsew", padx=(8, 0), pady=8) + right.grid_columnconfigure(0, weight=1) + right.grid_rowconfigure(1, weight=1) + right.grid_rowconfigure(3, weight=1) + + self.include_dirs = _ListEditor(left, "Cartelle monitorate", browse_dirs=True) + self.include_dirs.grid(row=0, column=0, rowspan=2, sticky="nsew", padx=12, pady=12) + + self.extensions = _ListEditor(right, "Estensioni incluse") + self.extensions.grid(row=0, column=0, rowspan=2, sticky="nsew", padx=12, pady=12) + + self.exclude_dirs = _ListEditor(left, "Cartelle escluse", browse_dirs=True) + self.exclude_dirs.grid(row=2, column=0, rowspan=2, sticky="nsew", padx=12, pady=12) + + self.exclude_patterns = _ListEditor(right, "Pattern esclusi") + self.exclude_patterns.grid(row=2, column=0, rowspan=2, sticky="nsew", padx=12, pady=12) + + backup_frame = ctk.CTkFrame(self.general_tab) + backup_frame.grid(row=1, column=0, columnspan=2, sticky="ew", padx=0, pady=(0, 8)) + backup_frame.grid_columnconfigure(1, weight=1) + + self.server_host_var = tk.StringVar() + self.server_port_var = tk.StringVar() + self.remote_destination_var = tk.StringVar() + self.rsync_path_var = tk.StringVar() + self.shutdown_on_success_var = tk.BooleanVar() + + _label_entry(backup_frame, "Server", self.server_host_var, 0) + _label_entry(backup_frame, "Porta TCP", self.server_port_var, 1) + _label_entry(backup_frame, "Destinazione remota", self.remote_destination_var, 2) + _label_entry(backup_frame, "Percorso rsync", self.rsync_path_var, 3) + + shutdown_check = ctk.CTkCheckBox( + backup_frame, + text="Spegni il PC se il backup termina correttamente", + variable=self.shutdown_on_success_var, + ) + shutdown_check.grid(row=4, column=0, columnspan=2, sticky="w", padx=12, pady=8) + + actions = ctk.CTkFrame(self.general_tab, fg_color="transparent") + actions.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(0, 8)) + actions.grid_columnconfigure(0, weight=1) + + save_button = ctk.CTkButton(actions, text="Salva configurazione", command=self._save_config) + save_button.grid(row=0, column=1, padx=8, pady=4) + + reload_button = ctk.CTkButton(actions, text="Ricarica", command=self._reload_config) + reload_button.grid(row=0, column=2, padx=8, pady=4) + + def _build_queue_tab(self) -> None: + self.queue_tab.grid_columnconfigure(0, weight=1) + self.queue_tab.grid_rowconfigure(1, weight=1) + + toolbar = ctk.CTkFrame(self.queue_tab, fg_color="transparent") + toolbar.grid(row=0, column=0, sticky="ew", padx=8, pady=8) + toolbar.grid_columnconfigure(0, weight=1) + + self.queue_count_label = ctk.CTkLabel(toolbar, text="File in coda: 0", anchor="w") + self.queue_count_label.grid(row=0, column=0, sticky="ew", padx=4) + + refresh_button = ctk.CTkButton(toolbar, text="Aggiorna", command=self._refresh_queue) + refresh_button.grid(row=0, column=1, padx=4) + + remove_button = ctk.CTkButton(toolbar, text="Rimuovi selezionato", command=self._remove_selected_pending) + remove_button.grid(row=0, column=2, padx=4) + + list_frame = ctk.CTkFrame(self.queue_tab) + list_frame.grid(row=1, column=0, sticky="nsew", padx=8, pady=(0, 8)) + list_frame.grid_columnconfigure(0, weight=1) + list_frame.grid_rowconfigure(0, weight=1) + + self.queue_list = tk.Listbox(list_frame, activestyle="dotbox", exportselection=False) + self.queue_list.grid(row=0, column=0, sticky="nsew", padx=(8, 0), pady=8) + + scrollbar = ctk.CTkScrollbar(list_frame, command=self.queue_list.yview) + scrollbar.grid(row=0, column=1, sticky="ns", padx=(4, 8), pady=8) + self.queue_list.configure(yscrollcommand=scrollbar.set) + + def _load_values(self) -> None: + watch = self.data.setdefault("watch", {}) + backup = self.data.setdefault("backup", {}) + server_check = backup.setdefault("server_check", {}) + + self.include_dirs.set_items(watch.get("include_dirs", [])) + self.extensions.set_items(watch.get("include_extensions", [])) + self.exclude_dirs.set_items(watch.get("exclude_dirs", [])) + self.exclude_patterns.set_items(watch.get("exclude_patterns", [])) + + self.server_host_var.set(str(backup.get("server_host", ""))) + self.server_port_var.set(str(server_check.get("port", 22))) + self.remote_destination_var.set(str(backup.get("remote_destination", ""))) + self.rsync_path_var.set(str(backup.get("rsync_path", "rsync"))) + self.shutdown_on_success_var.set(bool(backup.get("shutdown_on_success", True))) + + def _save_config(self) -> None: + watch = self.data.setdefault("watch", {}) + backup = self.data.setdefault("backup", {}) + server_check = backup.setdefault("server_check", {}) + + watch["include_dirs"] = self.include_dirs.items() + watch["include_extensions"] = self.extensions.items() + watch["exclude_dirs"] = self.exclude_dirs.items() + watch["exclude_patterns"] = self.exclude_patterns.items() + + backup["server_host"] = self.server_host_var.get().strip() + backup["remote_destination"] = self.remote_destination_var.get().strip() + backup["rsync_path"] = self.rsync_path_var.get().strip() or "rsync" + backup["shutdown_on_success"] = bool(self.shutdown_on_success_var.get()) + server_check["port"] = _safe_int(self.server_port_var.get(), 22) + + save_config_data(self.data, self.config_path) + self.app_config = load_config(self.config_path) + self.registry = ChangeRegistry(self.app_config.storage.change_registry) + messagebox.showinfo("Bak&Rest", "Configurazione salvata.") + + def _reload_config(self) -> None: + self.data = load_config_data(self.config_path) + self.app_config = load_config(self.config_path) + self.registry = ChangeRegistry(self.app_config.storage.change_registry) + self._load_values() + self._refresh_queue() + + def _refresh_queue(self) -> None: + self.pending_events = self.registry.list_pending() + self.queue_list.delete(0, tk.END) + for event in self.pending_events: + self.queue_list.insert(tk.END, f"{event.event_type} | {event.path}") + self.queue_count_label.configure(text=f"File in coda: {len(self.pending_events)}") + + def _remove_selected_pending(self) -> None: + selection = self.queue_list.curselection() + if not selection: + messagebox.showinfo("Bak&Rest", "Seleziona un file dalla coda.") + return + + event = self.pending_events[selection[0]] + dialog = RemovePendingDialog(self, event.path) + self.wait_window(dialog) + if dialog.choice == "file": + self.registry.mark_ignored(event.path) + elif dialog.choice == "folder": + folder = str(Path(event.path).parent) + self._add_exclude_dir(folder) + self.registry.mark_status(self._pending_paths_inside(folder), "ignored") + save_config_data(self.data, self.config_path) + self.exclude_dirs.set_items(self.data.setdefault("watch", {}).get("exclude_dirs", [])) + else: + return + self._refresh_queue() + + def _add_exclude_dir(self, folder: str) -> None: + watch = self.data.setdefault("watch", {}) + exclude_dirs = list(watch.get("exclude_dirs", [])) + if folder not in exclude_dirs: + exclude_dirs.append(folder) + watch["exclude_dirs"] = exclude_dirs + + def _pending_paths_inside(self, folder: str) -> list[str]: + folder_path = Path(folder) + paths: list[str] = [] + for event in self.pending_events: + try: + Path(event.path).relative_to(folder_path) + paths.append(event.path) + except ValueError: + continue + return paths + + +class RemovePendingDialog(ctk.CTkToplevel): + def __init__(self, parent: ctk.CTk, file_path: str) -> None: + super().__init__(parent) + self.choice: str | None = None + self.title("Rimuovi dalla coda") + self.geometry("520x190") + self.resizable(False, False) + self.transient(parent) + self.grab_set() + + self.grid_columnconfigure(0, weight=1) + label = ctk.CTkLabel( + self, + text="Vuoi rimuovere solo il file o isolare dal backup la cartella che lo contiene?", + wraplength=470, + justify="left", + ) + label.grid(row=0, column=0, sticky="ew", padx=18, pady=(18, 8)) + + path_label = ctk.CTkLabel(self, text=file_path, wraplength=470, justify="left") + path_label.grid(row=1, column=0, sticky="ew", padx=18, pady=(0, 16)) + + buttons = ctk.CTkFrame(self, fg_color="transparent") + buttons.grid(row=2, column=0, sticky="e", padx=12, pady=12) + + ctk.CTkButton(buttons, text="Solo file", command=lambda: self._close("file")).grid(row=0, column=0, padx=6) + ctk.CTkButton(buttons, text="Isola cartella", command=lambda: self._close("folder")).grid(row=0, column=1, padx=6) + ctk.CTkButton(buttons, text="Annulla", command=lambda: self._close(None)).grid(row=0, column=2, padx=6) + + def _close(self, choice: str | None) -> None: + self.choice = choice + self.destroy() + + +class _ListEditor(ctk.CTkFrame): + def __init__(self, parent: ctk.CTkBaseClass, title: str, browse_dirs: bool = False) -> None: + super().__init__(parent) + self.browse_dirs = browse_dirs + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(1, weight=1) + + label = ctk.CTkLabel(self, text=title, anchor="w") + label.grid(row=0, column=0, columnspan=3, sticky="ew", padx=10, pady=(8, 4)) + + self.listbox = tk.Listbox(self, activestyle="dotbox", exportselection=False) + self.listbox.grid(row=1, column=0, columnspan=3, sticky="nsew", padx=10, pady=(0, 8)) + + self.entry_var = tk.StringVar() + entry = ctk.CTkEntry(self, textvariable=self.entry_var) + entry.grid(row=2, column=0, sticky="ew", padx=(10, 4), pady=(0, 10)) + + add_button = ctk.CTkButton(self, text="+", width=42, command=self._add) + add_button.grid(row=2, column=1, padx=4, pady=(0, 10)) + + remove_button = ctk.CTkButton(self, text="-", width=42, command=self._remove) + remove_button.grid(row=2, column=2, padx=(4, 10), pady=(0, 10)) + + if browse_dirs: + browse_button = ctk.CTkButton(self, text="Sfoglia", command=self._browse) + browse_button.grid(row=3, column=0, columnspan=3, sticky="ew", padx=10, pady=(0, 10)) + + def set_items(self, items: list[str]) -> None: + self.listbox.delete(0, tk.END) + for item in items: + self.listbox.insert(tk.END, item) + + def items(self) -> list[str]: + return [str(self.listbox.get(index)) for index in range(self.listbox.size())] + + def _add(self) -> None: + value = self.entry_var.get().strip() + if not value: + return + if value not in self.items(): + self.listbox.insert(tk.END, value) + self.entry_var.set("") + + def _remove(self) -> None: + selection = self.listbox.curselection() + if selection: + self.listbox.delete(selection[0]) + + def _browse(self) -> None: + directory = filedialog.askdirectory() + if directory: + self.entry_var.set(directory) + self._add() + + +def _label_entry(parent: ctk.CTkFrame, label: str, variable: tk.StringVar, row: int) -> None: + ctk.CTkLabel(parent, text=label, anchor="w").grid(row=row, column=0, sticky="w", padx=12, pady=6) + ctk.CTkEntry(parent, textvariable=variable).grid(row=row, column=1, sticky="ew", padx=12, pady=6) + + +def _safe_int(value: str, default: int) -> int: + try: + return int(value) + except ValueError: + return default + + +def main() -> int: + app = BakRestConfigApp() + app.mainloop() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/bakrest/registry.py b/src/bakrest/registry.py index 1438494..8beca37 100644 --- a/src/bakrest/registry.py +++ b/src/bakrest/registry.py @@ -77,6 +77,9 @@ class ChangeRegistry: ) connection.commit() + def mark_ignored(self, path: str) -> None: + self.mark_status([path], "ignored") + def _initialize(self) -> None: with closing(self._connect()) as connection: connection.execute( diff --git a/tests/test_config_save.py b/tests/test_config_save.py new file mode 100644 index 0000000..362b661 --- /dev/null +++ b/tests/test_config_save.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from pathlib import Path + +from bakrest.config import load_config_data, save_config_data + + +def test_save_config_data_round_trips(tmp_path: Path) -> None: + path = tmp_path / "config.toml" + data = {"watch": {"include_dirs": [str(tmp_path)], "include_extensions": [".jpg"]}} + + save_config_data(data, path) + + assert load_config_data(path) == data diff --git a/tests/test_registry.py b/tests/test_registry.py new file mode 100644 index 0000000..29bb072 --- /dev/null +++ b/tests/test_registry.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from pathlib import Path + +from bakrest.registry import ChangeEvent, ChangeRegistry + + +def test_pending_changes_persist_until_marked(tmp_path: Path) -> None: + db_path = tmp_path / "changes.sqlite" + first = ChangeRegistry(db_path) + first.record(ChangeEvent(path=str(tmp_path / "image.jpg"), event_type="modified")) + + second = ChangeRegistry(db_path) + + assert second.pending_count() == 1 + + +def test_mark_ignored_removes_from_pending_queue(tmp_path: Path) -> None: + db_path = tmp_path / "changes.sqlite" + registry = ChangeRegistry(db_path) + path = str(tmp_path / "image.jpg") + registry.record(ChangeEvent(path=path, event_type="modified")) + + registry.mark_ignored(path) + + assert registry.pending_count() == 0