From 9af05f2c8659a93183d74d9de93108dd7dd8cf82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Miquel?= Date: Sat, 22 Aug 2026 11:55:17 +0200 Subject: [PATCH] =?UTF-8?q?Am=C3=A9liorations=20GUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- copienator_gui/app.py | 178 +++++++++++++++++++++++++++++++++---- copienator_gui/workflow.py | 2 +- tests/test_gui_core.py | 45 +++++++++- 3 files changed, 205 insertions(+), 20 deletions(-) diff --git a/copienator_gui/app.py b/copienator_gui/app.py index 67b698d..be1ed66 100644 --- a/copienator_gui/app.py +++ b/copienator_gui/app.py @@ -8,7 +8,7 @@ from tkinter import filedialog, messagebox, ttk from typing import Any from copienator import ExitCode -from platform_utils import WindowsLabelError, validate_windows_labels +from platform_utils import WindowsLabelError, open_path, validate_windows_labels from .diagnostics import collect_diagnostics from .runner import ProcessRunner @@ -35,6 +35,65 @@ STATUS_LABELS = { "detected": "Détectée", } +DEFAULT_HTTPS_PROXY = "http://10.0.0.1:3128" + + +def copy_pdf_paths(evaluation: Path) -> list[Path]: + """List the most relevant version of each scanned copy.""" + locations = (evaluation / "Copies", evaluation, evaluation / "Copies Originales") + for location in locations: + if not location.is_dir(): + continue + copies = sorted( + ( + path + for path in location.glob("*.pdf") + if path.name.casefold() not in {"enonce.pdf", "énoncé.pdf"} + ), + key=lambda path: path.name.casefold(), + ) + if copies: + return copies + return [] + + +def plotting_shortcut_lines() -> list[str]: + try: + from config import PLOTTING_KB + except (ImportError, AttributeError): + from default_config import PLOTTING_KB + + labels = ( + ("OK", "valider et passer à la suivante"), + ("previous", "revenir à la précédente"), + ("edit", "éditer le fichier JSON"), + ("open pdf", "ouvrir la copie traitée"), + ("open original pdf", "ouvrir la copie originale"), + ("open eval", "ouvrir l’énoncé"), + ) + display_names = {"": "Entrée", "": "Échap"} + lines = [ + f"{display_names.get(PLOTTING_KB[action], PLOTTING_KB[action])} : {description}" + for action, description in labels + ] + lines.append("Échap : fermer la fenêtre") + return lines + + +def build_runner_environment( + base: dict[str, str], api_key: str, proxy: str, use_proxy: bool +) -> dict[str, str]: + environment = dict(base) + environment["PYTHONUNBUFFERED"] = "1" + environment["PYTHONIOENCODING"] = "utf-8" + if api_key.strip(): + environment["GEMINI_API_KEY"] = api_key.strip() + environment.pop("HTTPS_PROXY", None) + environment.pop("https_proxy", None) + if use_proxy and proxy.strip(): + environment["HTTPS_PROXY"] = proxy.strip() + return environment + def process_status(return_code: int, interrupted: bool = False) -> str: if interrupted or return_code == ExitCode.INTERRUPTED: @@ -76,6 +135,7 @@ class CopienatorApp(tk.Tk): self.active_step_id: str | None = None self.current_step: StepDefinition | None = None self.arg_vars: dict[str, tk.Variable] = {} + self.copy_paths: dict[str, Path] = {} self._rendering = False self.title("Copienator — assistant de correction") @@ -85,7 +145,9 @@ class CopienatorApp(tk.Tk): self.evaluation_var = tk.StringVar() self.api_key_var = tk.StringVar(value=os.environ.get("GEMINI_API_KEY", "")) - self.proxy_var = tk.StringVar(value=os.environ.get("HTTPS_PROXY", "")) + self.proxy_var = tk.StringVar(value=DEFAULT_HTTPS_PROXY) + self.use_proxy_var = tk.BooleanVar(value=False) + self.copy_var = tk.StringVar() self.variant_var = tk.StringVar() self.extra_var = tk.StringVar() self.command_var = tk.StringVar(value="Sélectionnez une étape.") @@ -113,13 +175,17 @@ class CopienatorApp(tk.Tk): top = ttk.Frame(self, padding=(10, 8)) top.grid(row=0, column=0, sticky="ew") - top.columnconfigure(1, weight=1) - ttk.Label(top, text="Évaluation").grid(row=0, column=0, sticky="w", padx=(0, 8)) + top.columnconfigure(2, weight=1) + ttk.Button(top, text="Charger", command=self._load_evaluation).grid( + row=0, column=0, padx=(0, 8) + ) + ttk.Label(top, text="Évaluation").grid(row=0, column=1, sticky="w", padx=(0, 8)) path_entry = ttk.Entry(top, textvariable=self.evaluation_var) - path_entry.grid(row=0, column=1, sticky="ew") + path_entry.grid(row=0, column=2, sticky="ew") path_entry.bind("", lambda _event: self._load_evaluation()) - ttk.Button(top, text="Parcourir…", command=self._browse_evaluation).grid(row=0, column=2, padx=6) - ttk.Button(top, text="Charger", command=self._load_evaluation).grid(row=0, column=3) + ttk.Button(top, text="Parcourir…", command=self._browse_evaluation).grid( + row=0, column=3, padx=6 + ) ttk.Button(top, text="Diagnostic…", command=self._show_diagnostics).grid(row=0, column=4, padx=(6, 0)) ttk.Label(top, text="Clé Gemini").grid(row=1, column=0, sticky="w", padx=(0, 8), pady=(7, 0)) @@ -127,9 +193,15 @@ class CopienatorApp(tk.Tk): row=1, column=1, sticky="w", pady=(7, 0) ) environment = ttk.Frame(top) - environment.grid(row=1, column=2, columnspan=2, sticky="e", pady=(7, 0)) - ttk.Label(environment, text="HTTPS_PROXY").pack(side="left", padx=(0, 6)) - ttk.Entry(environment, textvariable=self.proxy_var, width=28).pack(side="left") + environment.grid(row=1, column=2, columnspan=3, sticky="e", pady=(7, 0)) + ttk.Checkbutton( + environment, + text="Utiliser le proxy HTTPS", + variable=self.use_proxy_var, + command=self._toggle_proxy, + ).pack(side="left", padx=(0, 6)) + self.proxy_entry = ttk.Entry(environment, textvariable=self.proxy_var, width=28, state="disabled") + self.proxy_entry.pack(side="left") profile = "standard + personnel" if show_personal_steps else "standard" ttk.Label(environment, text=f"Profil : {profile}").pack(side="left", padx=(12, 0)) @@ -269,6 +341,9 @@ class CopienatorApp(tk.Tk): self.evaluation_var.set(selected) self._load_evaluation() + def _toggle_proxy(self) -> None: + self.proxy_entry.configure(state="normal" if self.use_proxy_var.get() else "disabled") + def _load_evaluation(self) -> None: evaluation = self.evaluation if not evaluation or not evaluation.is_dir(): @@ -437,7 +512,7 @@ class CopienatorApp(tk.Tk): description += "\nPrérequis non détectés : " + ", ".join(missing) self.description_label.configure(text=description) - row = 0 + row = self._render_context_controls(step, 0) if len(step.variants) > 1: ttk.Label(self.form, text="Mode").grid(row=row, column=0, sticky="w", pady=4, padx=(0, 8)) labels = [variant.label for variant in step.variants] @@ -499,6 +574,74 @@ class CopienatorApp(tk.Tk): self._update_command_preview() self._update_controls() + def _render_context_controls(self, step: StepDefinition, row: int) -> int: + if step.id == "review_persp": + ttk.Button( + self.form, + text="Ouvrir le dossier Persp", + command=self._open_persp, + ).grid(row=row, column=0, columnspan=2, sticky="w", pady=(0, 8)) + row += 1 + + if step.section == "Prétraitement des copies": + evaluation = self.evaluation + paths = copy_pdf_paths(evaluation) if evaluation else [] + self.copy_paths = {path.name: path for path in paths} + names = list(self.copy_paths) + copies = ttk.LabelFrame(self.form, text="Copies détectées", padding=7) + copies.grid(row=row, column=0, columnspan=2, sticky="ew", pady=(0, 8)) + copies.columnconfigure(0, weight=1) + summary = ( + f"{len(names)} copie(s) : {', '.join(names)}" + if names + else "Aucune copie PDF détectée." + ) + ttk.Label(copies, text=summary, wraplength=570, justify="left").grid( + row=0, column=0, columnspan=2, sticky="ew" + ) + self.copy_var.set(names[0] if names else "") + selector = ttk.Combobox( + copies, + textvariable=self.copy_var, + values=names, + state="readonly" if names else "disabled", + ) + selector.grid(row=1, column=0, sticky="ew", pady=(7, 0)) + ttk.Button( + copies, + text="Afficher la copie", + command=self._open_selected_copy, + state="normal" if names else "disabled", + ).grid(row=1, column=1, padx=(6, 0), pady=(7, 0)) + row += 1 + + if step.id == "plotting": + shortcuts = ttk.LabelFrame(self.form, text="Raccourcis clavier", padding=7) + shortcuts.grid(row=row, column=0, columnspan=2, sticky="ew", pady=(0, 8)) + ttk.Label( + shortcuts, + text="\n".join(plotting_shortcut_lines()), + justify="left", + ).grid(row=0, column=0, sticky="w") + row += 1 + return row + + def _open_persp(self) -> None: + evaluation = self.evaluation + self._open_desktop_path(evaluation / "Persp" if evaluation else None, "dossier Persp") + + def _open_selected_copy(self) -> None: + self._open_desktop_path(self.copy_paths.get(self.copy_var.get()), "copie") + + def _open_desktop_path(self, path: Path | None, label: str) -> None: + if path is None or not path.exists(): + messagebox.showerror("Élément introuvable", f"Le {label} n’existe pas encore.") + return + try: + open_path(path) + except (OSError, RuntimeError) as exc: + messagebox.showerror("Ouverture impossible", str(exc)) + def _select_variant(self, index: int) -> None: if not self.current_step or index < 0: return @@ -640,13 +783,12 @@ class CopienatorApp(tk.Tk): assert workspace is not None log_path = workspace.log_path(step.id) - environment = os.environ.copy() - environment["PYTHONUNBUFFERED"] = "1" - environment["PYTHONIOENCODING"] = "utf-8" - if self.api_key_var.get().strip(): - environment["GEMINI_API_KEY"] = self.api_key_var.get().strip() - if self.proxy_var.get().strip(): - environment["HTTPS_PROXY"] = self.proxy_var.get().strip() + environment = build_runner_environment( + os.environ, + self.api_key_var.get(), + self.proxy_var.get(), + self.use_proxy_var.get(), + ) self._append_console(f"\n$ {command_display(command)}\n") try: diff --git a/copienator_gui/workflow.py b/copienator_gui/workflow.py index 384e5d2..bfa9b81 100644 --- a/copienator_gui/workflow.py +++ b/copienator_gui/workflow.py @@ -109,7 +109,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: StepDefinition( "review_persp", "Prétraitement de l’énoncé", - "Relire les barèmes dans Persp", + "Relire les barèmes", "Étape manuelle facultative : vérifier et modifier les instructions de correction.", (manual("review"),), optional=True, diff --git a/tests/test_gui_core.py b/tests/test_gui_core.py index 7e28009..26e69ce 100644 --- a/tests/test_gui_core.py +++ b/tests/test_gui_core.py @@ -31,7 +31,14 @@ from copienator import ( from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides from copienator.annotation_data import AnnotationLoadResult, load_annotation_data from copienator.filesystem import staged_directory, staged_files -from copienator_gui.app import has_manual_conflicts, process_status +from copienator_gui.app import ( + DEFAULT_HTTPS_PROXY, + build_runner_environment, + copy_pdf_paths, + has_manual_conflicts, + plotting_shortcut_lines, + process_status, +) from copienator_gui.diagnostics import collect_diagnostics from copienator_gui.runner import ProcessRunner from copienator_gui.state import StateStore @@ -1396,6 +1403,42 @@ class WorkflowTests(unittest.TestCase): ) self.assertEqual(skip_without_conflicts, {"manual_resolution"}) + def test_review_persp_has_shorter_title(self) -> None: + self.assertEqual(self.steps["review_persp"].title, "Relire les barèmes") + + def test_copy_listing_prefers_processed_copies(self) -> None: + with tempfile.TemporaryDirectory() as directory: + evaluation = Path(directory) + (evaluation / "enonce.pdf").touch() + (evaluation / "scan.pdf").touch() + copies = evaluation / "Copies" + copies.mkdir() + (copies / "Copie02.pdf").touch() + (copies / "Copie01.pdf").touch() + + self.assertEqual( + [path.name for path in copy_pdf_paths(evaluation)], + ["Copie01.pdf", "Copie02.pdf"], + ) + + def test_plotting_shortcuts_describe_open_actions(self) -> None: + rendered = "\n".join(plotting_shortcut_lines()) + self.assertIn("ouvrir l’énoncé", rendered) + self.assertIn("ouvrir la copie traitée", rendered) + self.assertIn("ouvrir la copie originale", rendered) + + def test_proxy_is_opt_in_and_prefilled(self) -> None: + base = {"HTTPS_PROXY": "http://system-proxy", "OTHER": "kept"} + without_proxy = build_runner_environment( + base, " secret ", DEFAULT_HTTPS_PROXY, False + ) + with_proxy = build_runner_environment(base, "", DEFAULT_HTTPS_PROXY, True) + + self.assertNotIn("HTTPS_PROXY", without_proxy) + self.assertEqual(without_proxy["GEMINI_API_KEY"], "secret") + self.assertEqual(without_proxy["OTHER"], "kept") + self.assertEqual(with_proxy["HTTPS_PROXY"], DEFAULT_HTTPS_PROXY) + def test_manual_conflicts_ignore_blank_and_comment_lines(self) -> None: with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "manual_resolutions.txt"