Améliorations GUI
This commit is contained in:
+160
-18
@@ -8,7 +8,7 @@ from tkinter import filedialog, messagebox, ttk
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from copienator import ExitCode
|
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 .diagnostics import collect_diagnostics
|
||||||
from .runner import ProcessRunner
|
from .runner import ProcessRunner
|
||||||
@@ -35,6 +35,65 @@ STATUS_LABELS = {
|
|||||||
"detected": "Détectée",
|
"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 = {"<Return>": "Entrée", "<Escape>": "É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:
|
def process_status(return_code: int, interrupted: bool = False) -> str:
|
||||||
if interrupted or return_code == ExitCode.INTERRUPTED:
|
if interrupted or return_code == ExitCode.INTERRUPTED:
|
||||||
@@ -76,6 +135,7 @@ class CopienatorApp(tk.Tk):
|
|||||||
self.active_step_id: str | None = None
|
self.active_step_id: str | None = None
|
||||||
self.current_step: StepDefinition | None = None
|
self.current_step: StepDefinition | None = None
|
||||||
self.arg_vars: dict[str, tk.Variable] = {}
|
self.arg_vars: dict[str, tk.Variable] = {}
|
||||||
|
self.copy_paths: dict[str, Path] = {}
|
||||||
self._rendering = False
|
self._rendering = False
|
||||||
|
|
||||||
self.title("Copienator — assistant de correction")
|
self.title("Copienator — assistant de correction")
|
||||||
@@ -85,7 +145,9 @@ class CopienatorApp(tk.Tk):
|
|||||||
|
|
||||||
self.evaluation_var = tk.StringVar()
|
self.evaluation_var = tk.StringVar()
|
||||||
self.api_key_var = tk.StringVar(value=os.environ.get("GEMINI_API_KEY", ""))
|
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.variant_var = tk.StringVar()
|
||||||
self.extra_var = tk.StringVar()
|
self.extra_var = tk.StringVar()
|
||||||
self.command_var = tk.StringVar(value="Sélectionnez une étape.")
|
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 = ttk.Frame(self, padding=(10, 8))
|
||||||
top.grid(row=0, column=0, sticky="ew")
|
top.grid(row=0, column=0, sticky="ew")
|
||||||
top.columnconfigure(1, weight=1)
|
top.columnconfigure(2, weight=1)
|
||||||
ttk.Label(top, text="Évaluation").grid(row=0, column=0, sticky="w", padx=(0, 8))
|
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 = 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("<Return>", lambda _event: self._load_evaluation())
|
path_entry.bind("<Return>", 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="Parcourir…", command=self._browse_evaluation).grid(
|
||||||
ttk.Button(top, text="Charger", command=self._load_evaluation).grid(row=0, column=3)
|
row=0, column=3, padx=6
|
||||||
|
)
|
||||||
ttk.Button(top, text="Diagnostic…", command=self._show_diagnostics).grid(row=0, column=4, padx=(6, 0))
|
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))
|
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)
|
row=1, column=1, sticky="w", pady=(7, 0)
|
||||||
)
|
)
|
||||||
environment = ttk.Frame(top)
|
environment = ttk.Frame(top)
|
||||||
environment.grid(row=1, column=2, columnspan=2, sticky="e", pady=(7, 0))
|
environment.grid(row=1, column=2, columnspan=3, sticky="e", pady=(7, 0))
|
||||||
ttk.Label(environment, text="HTTPS_PROXY").pack(side="left", padx=(0, 6))
|
ttk.Checkbutton(
|
||||||
ttk.Entry(environment, textvariable=self.proxy_var, width=28).pack(side="left")
|
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"
|
profile = "standard + personnel" if show_personal_steps else "standard"
|
||||||
ttk.Label(environment, text=f"Profil : {profile}").pack(side="left", padx=(12, 0))
|
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.evaluation_var.set(selected)
|
||||||
self._load_evaluation()
|
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:
|
def _load_evaluation(self) -> None:
|
||||||
evaluation = self.evaluation
|
evaluation = self.evaluation
|
||||||
if not evaluation or not evaluation.is_dir():
|
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)
|
description += "\nPrérequis non détectés : " + ", ".join(missing)
|
||||||
self.description_label.configure(text=description)
|
self.description_label.configure(text=description)
|
||||||
|
|
||||||
row = 0
|
row = self._render_context_controls(step, 0)
|
||||||
if len(step.variants) > 1:
|
if len(step.variants) > 1:
|
||||||
ttk.Label(self.form, text="Mode").grid(row=row, column=0, sticky="w", pady=4, padx=(0, 8))
|
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]
|
labels = [variant.label for variant in step.variants]
|
||||||
@@ -499,6 +574,74 @@ class CopienatorApp(tk.Tk):
|
|||||||
self._update_command_preview()
|
self._update_command_preview()
|
||||||
self._update_controls()
|
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:
|
def _select_variant(self, index: int) -> None:
|
||||||
if not self.current_step or index < 0:
|
if not self.current_step or index < 0:
|
||||||
return
|
return
|
||||||
@@ -640,13 +783,12 @@ class CopienatorApp(tk.Tk):
|
|||||||
assert workspace is not None
|
assert workspace is not None
|
||||||
log_path = workspace.log_path(step.id)
|
log_path = workspace.log_path(step.id)
|
||||||
|
|
||||||
environment = os.environ.copy()
|
environment = build_runner_environment(
|
||||||
environment["PYTHONUNBUFFERED"] = "1"
|
os.environ,
|
||||||
environment["PYTHONIOENCODING"] = "utf-8"
|
self.api_key_var.get(),
|
||||||
if self.api_key_var.get().strip():
|
self.proxy_var.get(),
|
||||||
environment["GEMINI_API_KEY"] = self.api_key_var.get().strip()
|
self.use_proxy_var.get(),
|
||||||
if self.proxy_var.get().strip():
|
)
|
||||||
environment["HTTPS_PROXY"] = self.proxy_var.get().strip()
|
|
||||||
|
|
||||||
self._append_console(f"\n$ {command_display(command)}\n")
|
self._append_console(f"\n$ {command_display(command)}\n")
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
StepDefinition(
|
StepDefinition(
|
||||||
"review_persp",
|
"review_persp",
|
||||||
"Prétraitement de l’énoncé",
|
"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.",
|
"Étape manuelle facultative : vérifier et modifier les instructions de correction.",
|
||||||
(manual("review"),),
|
(manual("review"),),
|
||||||
optional=True,
|
optional=True,
|
||||||
|
|||||||
+44
-1
@@ -31,7 +31,14 @@ from copienator import (
|
|||||||
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
|
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
|
||||||
from copienator.annotation_data import AnnotationLoadResult, load_annotation_data
|
from copienator.annotation_data import AnnotationLoadResult, load_annotation_data
|
||||||
from copienator.filesystem import staged_directory, staged_files
|
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.diagnostics import collect_diagnostics
|
||||||
from copienator_gui.runner import ProcessRunner
|
from copienator_gui.runner import ProcessRunner
|
||||||
from copienator_gui.state import StateStore
|
from copienator_gui.state import StateStore
|
||||||
@@ -1396,6 +1403,42 @@ class WorkflowTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(skip_without_conflicts, {"manual_resolution"})
|
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:
|
def test_manual_conflicts_ignore_blank_and_comment_lines(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
path = Path(directory) / "manual_resolutions.txt"
|
path = Path(directory) / "manual_resolutions.txt"
|
||||||
|
|||||||
Reference in New Issue
Block a user