Refaire fixes and GUI support
This commit is contained in:
+184
-24
@@ -3,11 +3,12 @@ from __future__ import annotations
|
||||
import os
|
||||
import queue
|
||||
import tkinter as tk
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
from typing import Any
|
||||
|
||||
from copienator import ExitCode
|
||||
from copienator import EvaluationWorkspace, ExitCode, atomic_write_json
|
||||
from copienator.platform import (
|
||||
WindowsLabelError,
|
||||
add_platform_executable_paths,
|
||||
@@ -16,6 +17,14 @@ from copienator.platform import (
|
||||
)
|
||||
|
||||
from .diagnostics import collect_diagnostics
|
||||
from .refaire import SECTION as REFAIRE_SECTION
|
||||
from .refaire import (
|
||||
RefaireSelection,
|
||||
available_copies,
|
||||
load_selection,
|
||||
resolve_layout,
|
||||
validate_selection,
|
||||
)
|
||||
from .runner import ProcessRunner
|
||||
from .state import StateStore
|
||||
from .workflow import (
|
||||
@@ -154,6 +163,9 @@ class CopienatorApp(tk.Tk):
|
||||
self.arg_vars: dict[str, tk.Variable] = {}
|
||||
self.copy_paths: dict[str, Path] = {}
|
||||
self._rendering = False
|
||||
self.refaire_panel: RefaireSelection | None = None
|
||||
self.pending_refaire_commands: list[list[str]] = []
|
||||
self.refaire_command_index = 0
|
||||
|
||||
self.title("Copienator — assistant de correction")
|
||||
self.geometry("1180x820")
|
||||
@@ -245,8 +257,19 @@ class CopienatorApp(tk.Tk):
|
||||
self.title_label.grid(row=0, column=0, sticky="w")
|
||||
self.description_label = ttk.Label(self.detail, text="", wraplength=680, justify="left")
|
||||
self.description_label.grid(row=1, column=0, sticky="ew", pady=(5, 8))
|
||||
self.form = ttk.Frame(self.detail)
|
||||
self.form.grid(row=2, column=0, sticky="nsew")
|
||||
form_container = ttk.Frame(self.detail)
|
||||
form_container.grid(row=2, column=0, sticky="nsew")
|
||||
form_container.columnconfigure(0, weight=1)
|
||||
form_container.rowconfigure(0, weight=1)
|
||||
self.form_canvas = tk.Canvas(form_container, highlightthickness=0, width=1, height=1)
|
||||
self.form_canvas.grid(row=0, column=0, sticky="nsew")
|
||||
form_scroll = ttk.Scrollbar(form_container, orient="vertical", command=self.form_canvas.yview)
|
||||
form_scroll.grid(row=0, column=1, sticky="ns")
|
||||
self.form_canvas.configure(yscrollcommand=form_scroll.set)
|
||||
self.form = ttk.Frame(self.form_canvas)
|
||||
form_window = self.form_canvas.create_window((0, 0), window=self.form, anchor="nw")
|
||||
self.form.bind("<Configure>", lambda _event: self.form_canvas.configure(scrollregion=self.form_canvas.bbox("all")))
|
||||
self.form_canvas.bind("<Configure>", lambda event: self.form_canvas.itemconfigure(form_window, width=event.width))
|
||||
self.form.columnconfigure(1, weight=1)
|
||||
|
||||
command_box = ttk.LabelFrame(self.detail, text="Commande", padding=6)
|
||||
@@ -362,6 +385,9 @@ class CopienatorApp(tk.Tk):
|
||||
self.proxy_entry.configure(state="normal" if self.use_proxy_var.get() else "disabled")
|
||||
|
||||
def _load_evaluation(self) -> None:
|
||||
if self.active_step_id or self.runner.running:
|
||||
messagebox.showinfo("Traitement en cours", "Attendez la fin du traitement avant de changer d’évaluation.")
|
||||
return
|
||||
evaluation = self.evaluation
|
||||
if not evaluation or not evaluation.is_dir():
|
||||
messagebox.showerror("Dossier invalide", "Choisissez un dossier d’évaluation existant.")
|
||||
@@ -392,13 +418,14 @@ class CopienatorApp(tk.Tk):
|
||||
|
||||
def _populate_tree(self) -> None:
|
||||
selected = self.current_step.id if self.current_step else None
|
||||
opened = {self.tree.item(item, "text"): self.tree.item(item, "open") for item in self.tree.get_children()}
|
||||
self.tree.delete(*self.tree.get_children())
|
||||
section_items: dict[str, str] = {}
|
||||
for step in self.steps:
|
||||
if step.section not in section_items:
|
||||
section_id = f"section:{len(section_items)}"
|
||||
section_items[step.section] = section_id
|
||||
self.tree.insert("", "end", iid=section_id, text=step.section, values=("",), open=True)
|
||||
self.tree.insert("", "end", iid=section_id, text=step.section, values=("",), open=opened.get(step.section, step.section != REFAIRE_SECTION))
|
||||
status = self._step_status(step)
|
||||
suffix = " (facultative)" if step.optional else ""
|
||||
self.tree.insert(
|
||||
@@ -513,9 +540,16 @@ class CopienatorApp(tk.Tk):
|
||||
if not step:
|
||||
return
|
||||
self._rendering = True
|
||||
redo = step.section == REFAIRE_SECTION
|
||||
self.console.configure(height=8 if redo else 14)
|
||||
self.rowconfigure(1, weight=4 if redo else 3)
|
||||
self.rowconfigure(2, weight=1 if redo else 2)
|
||||
self.form_canvas.configure(height=(340 if step.id == "refaire_selection" else 260) if redo else 200)
|
||||
self.form_canvas.yview_moveto(0)
|
||||
for child in self.form.winfo_children():
|
||||
child.destroy()
|
||||
self.arg_vars.clear()
|
||||
self.refaire_panel = None
|
||||
entry = self.state_store.step(step.id) if self.state_store.evaluation else {}
|
||||
saved_variant = entry.get("variant", step.variants[0].id)
|
||||
if saved_variant not in {variant.id for variant in step.variants}:
|
||||
@@ -544,6 +578,8 @@ class CopienatorApp(tk.Tk):
|
||||
variant = self._current_variant()
|
||||
evaluation_arg = self._evaluation_arg()
|
||||
for spec in step.arguments:
|
||||
if step.section == REFAIRE_SECTION:
|
||||
continue
|
||||
if spec.variants and variant.id not in spec.variants:
|
||||
continue
|
||||
value = values.get(spec.name, value_for_default(spec.default, evaluation_arg))
|
||||
@@ -582,27 +618,44 @@ class CopienatorApp(tk.Tk):
|
||||
row += 1
|
||||
row += 1
|
||||
|
||||
if not step.is_manual:
|
||||
if not step.is_manual and step.section != REFAIRE_SECTION:
|
||||
ttk.Label(self.form, text="Arguments supplémentaires").grid(
|
||||
row=row, column=0, sticky="w", pady=(10, 4), padx=(0, 8)
|
||||
)
|
||||
ttk.Entry(self.form, textvariable=self.extra_var).grid(row=row, column=1, sticky="ew", pady=(10, 4))
|
||||
|
||||
self.run_button.configure(text="Marquer terminée" if step.is_manual else "Exécuter")
|
||||
self.run_button.configure(text="Enregistrer la sélection" if step.id == "refaire_selection" else ("Marquer terminée" if step.is_manual else "Exécuter"))
|
||||
self.skip_button.configure(state="normal" if step.optional else "disabled")
|
||||
self._rendering = False
|
||||
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 == REFAIRE_SECTION:
|
||||
evaluation = self.evaluation
|
||||
if step.id in {"refaire_selection", "refaire_correct"}:
|
||||
row = self._correction_folder_buttons(row)
|
||||
if step.id == "refaire_selection" and evaluation:
|
||||
choices, preferred = self._annotation_directory_choices("export")
|
||||
draft = self.state_store.step(step.id).get("values", {})
|
||||
self.refaire_panel = RefaireSelection(self.form, evaluation, draft, choices, preferred)
|
||||
self.refaire_panel.grid(row=row, column=0, columnspan=2, sticky="nsew")
|
||||
else:
|
||||
try:
|
||||
entries = self._refaire_selection()
|
||||
summary = f"{len(entries)} copie(s) sélectionnée(s) :\n" + " ; ".join(f"{name} : {', '.join(labels) or 'toute la copie'}" for name, labels in entries[:5])
|
||||
if len(entries) > 5:
|
||||
summary += f" ; et {len(entries) - 5} autres (voir la sélection)."
|
||||
source = self.state_store.step("refaire_selection").get("values", {}).get("annotation_dir", "")
|
||||
choice = self.state_store.step("refaire_selection").get("values", {}).get("layout", "auto")
|
||||
layout = resolve_layout(entries, EvaluationWorkspace(evaluation).read_labels(), choice)
|
||||
summary += f"\nPassage principal : {source} — PDF à vérifier : {'par question' if layout == 'grouped' else 'par copie'}"
|
||||
except (OSError, ValueError, TypeError) as exc:
|
||||
summary = str(exc)
|
||||
ttk.Label(self.form, text=summary, wraplength=600, justify="left").grid(row=row, column=0, columnspan=2, sticky="w")
|
||||
return row + 1
|
||||
if step.id in {"review_persp", "correction"}:
|
||||
row = self._correction_folder_buttons(row)
|
||||
if step.section == "Prétraitement des copies":
|
||||
evaluation = self.evaluation
|
||||
paths = copy_pdf_paths(evaluation) if evaluation else []
|
||||
@@ -646,6 +699,14 @@ class CopienatorApp(tk.Tk):
|
||||
row += 1
|
||||
return row
|
||||
|
||||
def _correction_folder_buttons(self, row: int) -> int:
|
||||
buttons = ttk.Frame(self.form)
|
||||
buttons.grid(row=row, column=0, columnspan=2, sticky="w", pady=(0, 6))
|
||||
for directory, label in (("Sol", "Corrigés (Sol)"), ("Persp", "Consignes de notation (Persp)")):
|
||||
ttk.Button(buttons, text=label,
|
||||
command=lambda name=directory: self._open_desktop_path(self.evaluation / name if self.evaluation else None, f"dossier {name}")).pack(side="left", padx=(0, 6))
|
||||
return row + 1
|
||||
|
||||
def _annotation_directory_choices(self, step_id: str) -> tuple[tuple[str, ...], str]:
|
||||
evaluation = self.evaluation
|
||||
detected = detected_annotation_directories(evaluation) if evaluation else ()
|
||||
@@ -696,6 +757,10 @@ class CopienatorApp(tk.Tk):
|
||||
return next((variant for variant in self.current_step.variants if variant.id == selected), self.current_step.variants[0])
|
||||
|
||||
def _values(self) -> dict[str, object]:
|
||||
if self.current_step and self.current_step.section == REFAIRE_SECTION:
|
||||
if self.current_step.id == "refaire_selection" and self.refaire_panel:
|
||||
return self.refaire_panel.values()
|
||||
return {"target": self._evaluation_arg(), "annotation_dir": self.state_store.step("refaire_selection").get("values", {}).get("annotation_dir", "BGnot")}
|
||||
return {name: variable.get() for name, variable in self.arg_vars.items()}
|
||||
|
||||
def _save_current_form(self) -> None:
|
||||
@@ -715,9 +780,62 @@ class CopienatorApp(tk.Tk):
|
||||
evaluation = self.evaluation
|
||||
return evaluation_argument(self.repository, evaluation) if evaluation else "<évaluation>"
|
||||
|
||||
def _progression_ids(self, step_id: str) -> list[str]:
|
||||
redo = step_id.startswith("refaire_")
|
||||
return [step.id for step in self.steps if step.id.startswith("refaire_") == redo]
|
||||
|
||||
def _refaire_selection(self) -> list[list]:
|
||||
evaluation = self.evaluation
|
||||
if not evaluation or not self.state_store.evaluation:
|
||||
raise ValueError("Chargez d’abord une évaluation.")
|
||||
entry = self.state_store.step("refaire_selection")
|
||||
if entry.get("status") != "success":
|
||||
raise ValueError("Enregistrez d’abord les copies et les questions à refaire.")
|
||||
values = entry.get("values", {})
|
||||
selection = load_selection(evaluation)
|
||||
if selection != validate_selection(values.get("selection"), available_copies(evaluation), EvaluationWorkspace(evaluation).read_labels()):
|
||||
raise ValueError("La sélection a changé. Enregistrez-la avant de continuer.")
|
||||
source = values.get("annotation_dir")
|
||||
if source not in ANNOTATION_DIRECTORIES or not (evaluation / source).is_dir():
|
||||
raise ValueError("Choisissez un dossier existant pour le passage principal.")
|
||||
return selection
|
||||
|
||||
def _save_refaire_selection(self) -> None:
|
||||
if not self.refaire_panel or not self.evaluation:
|
||||
return
|
||||
try:
|
||||
values = self.refaire_panel.values()
|
||||
entries = validate_selection(values["selection"], available_copies(self.evaluation), EvaluationWorkspace(self.evaluation).read_labels())
|
||||
source = values["annotation_dir"]
|
||||
if source not in detected_annotation_directories(self.evaluation):
|
||||
raise ValueError("Choisissez un dossier existant pour le passage principal.")
|
||||
atomic_write_json(self.evaluation / "refaire.json", entries)
|
||||
self._mark_step("success")
|
||||
self._move_selection_from("refaire_selection", 1)
|
||||
except (OSError, ValueError, TypeError) as exc:
|
||||
messagebox.showerror("Sélection à vérifier", str(exc))
|
||||
|
||||
def _refaire_commands(self) -> list[list[str]]:
|
||||
assert self.current_step is not None
|
||||
selection = self._refaire_selection()
|
||||
values = self._values()
|
||||
targets = [self._evaluation_arg()]
|
||||
if self.current_step.id in {"refaire_review", "refaire_split"}:
|
||||
copies = available_copies(self.evaluation)
|
||||
targets = [str(copies[name]) for name, _labels in selection]
|
||||
variant = self._current_variant()
|
||||
if self.current_step.id == "refaire_annotate":
|
||||
choice = self.state_store.step("refaire_selection").get("values", {}).get("layout", "auto")
|
||||
layout = resolve_layout(selection, EvaluationWorkspace(self.evaluation).read_labels(), choice)
|
||||
variant = replace(variant, program="annotate-grouped" if layout == "grouped" else "annotate-checks")
|
||||
return [build_command(self.repository, self.current_step, variant,
|
||||
{**values, "target": target}, self._evaluation_arg()) for target in targets]
|
||||
|
||||
def _make_command(self) -> list[str]:
|
||||
if not self.current_step:
|
||||
return []
|
||||
if self.current_step.section == REFAIRE_SECTION:
|
||||
return self._refaire_commands()[0]
|
||||
return build_command(
|
||||
self.repository,
|
||||
self.current_step,
|
||||
@@ -731,11 +849,15 @@ class CopienatorApp(tk.Tk):
|
||||
if self._rendering or not self.current_step:
|
||||
return
|
||||
if self.current_step.is_manual:
|
||||
self.command_var.set("Étape manuelle — aucune commande ne sera exécutée.")
|
||||
self.command_var.set("La sélection sera utilisée pour toutes les étapes de ce parcours." if self.current_step.id == "refaire_selection" else "Étape manuelle — aucune commande ne sera exécutée.")
|
||||
return
|
||||
try:
|
||||
self.command_var.set(command_display(self._make_command()))
|
||||
except ValueError as exc:
|
||||
commands = self._refaire_commands() if self.current_step.section == REFAIRE_SECTION else [self._make_command()]
|
||||
preview = "\n".join(command_display(command) for command in commands[:2])
|
||||
if len(commands) > 2:
|
||||
preview += f"\nPuis {len(commands) - 2} autres copies, successivement."
|
||||
self.command_var.set(preview)
|
||||
except (OSError, ValueError, TypeError) as exc:
|
||||
self.command_var.set(f"Arguments invalides : {exc}")
|
||||
|
||||
def _browse_target_file(self, variable: tk.Variable) -> None:
|
||||
@@ -764,7 +886,7 @@ class CopienatorApp(tk.Tk):
|
||||
return False
|
||||
try:
|
||||
self._make_command()
|
||||
except ValueError as exc:
|
||||
except (OSError, ValueError, TypeError) as exc:
|
||||
messagebox.showerror("Arguments invalides", str(exc))
|
||||
return False
|
||||
return True
|
||||
@@ -775,11 +897,22 @@ class CopienatorApp(tk.Tk):
|
||||
if not step or not evaluation or not self.state_store.evaluation:
|
||||
messagebox.showerror("Évaluation absente", "Chargez d’abord un dossier d’évaluation.")
|
||||
return
|
||||
if self.runner.running:
|
||||
if self.active_step_id or self.runner.running:
|
||||
messagebox.showwarning("Traitement en cours", "Interrompez le traitement actuel avant d’en lancer un autre.")
|
||||
return
|
||||
if step.id == "refaire_selection":
|
||||
self._save_refaire_selection()
|
||||
return
|
||||
if step.section == REFAIRE_SECTION:
|
||||
try:
|
||||
self._refaire_selection()
|
||||
except (OSError, ValueError, TypeError) as exc:
|
||||
messagebox.showerror("Sélection à vérifier", str(exc))
|
||||
return
|
||||
if step.is_manual:
|
||||
self._mark_step("success")
|
||||
if step.section == REFAIRE_SECTION:
|
||||
self._move_selection_from(step.id, 1)
|
||||
return
|
||||
if os.name == "nt" and step.id != "statement":
|
||||
labels_path = evaluation / "labels"
|
||||
@@ -816,7 +949,7 @@ class CopienatorApp(tk.Tk):
|
||||
|
||||
self._save_current_form()
|
||||
run_values = self._values()
|
||||
ordered_ids = [item.id for item in self.steps]
|
||||
ordered_ids = self._progression_ids(step.id)
|
||||
self.state_store.invalidate_after(ordered_ids, step.id)
|
||||
self.state_store.update_step(
|
||||
step.id,
|
||||
@@ -837,6 +970,8 @@ class CopienatorApp(tk.Tk):
|
||||
self.use_proxy_var.get(),
|
||||
)
|
||||
|
||||
self.pending_refaire_commands = self._refaire_commands()[1:] if step.section == REFAIRE_SECTION else []
|
||||
self.refaire_command_index = 1
|
||||
self._append_console(f"\n$ {command_display(command)}\n")
|
||||
try:
|
||||
self.runner.start(command, self.repository, environment, log_path)
|
||||
@@ -846,6 +981,7 @@ class CopienatorApp(tk.Tk):
|
||||
{"step": step.id, "command": command_display(command), "status": "failed", "error": str(exc)}
|
||||
)
|
||||
self.active_step_id = None
|
||||
self.pending_refaire_commands = []
|
||||
self._append_console(f"Impossible de lancer la commande : {exc}\n")
|
||||
messagebox.showerror("Échec du lancement", str(exc))
|
||||
self._populate_tree()
|
||||
@@ -854,10 +990,10 @@ class CopienatorApp(tk.Tk):
|
||||
def _mark_step(
|
||||
self, status: str, *, automatic: bool = False, reason: str | None = None
|
||||
) -> None:
|
||||
if not self.current_step or not self.state_store.evaluation:
|
||||
if not self.current_step or not self.state_store.evaluation or self.active_step_id:
|
||||
return
|
||||
self._save_current_form()
|
||||
self.state_store.invalidate_after([item.id for item in self.steps], self.current_step.id)
|
||||
self.state_store.invalidate_after(self._progression_ids(self.current_step.id), self.current_step.id)
|
||||
self.state_store.update_step(self.current_step.id, status=status)
|
||||
history: dict[str, object] = {
|
||||
"step": self.current_step.id,
|
||||
@@ -875,7 +1011,10 @@ class CopienatorApp(tk.Tk):
|
||||
|
||||
def _skip_step(self) -> None:
|
||||
if self.current_step and self.current_step.optional:
|
||||
step_id = self.current_step.id
|
||||
self._mark_step("skipped")
|
||||
if step_id.startswith("refaire_"):
|
||||
self._move_selection_from(step_id, 1)
|
||||
|
||||
def _poll_runner(self) -> None:
|
||||
while True:
|
||||
@@ -899,6 +1038,26 @@ class CopienatorApp(tk.Tk):
|
||||
if not step_id:
|
||||
return
|
||||
status = process_status(return_code, interrupted)
|
||||
if status == "success" and self.pending_refaire_commands:
|
||||
command = self.pending_refaire_commands.pop(0)
|
||||
self.refaire_command_index += 1
|
||||
self._append_console(f"\n$ {command_display(command)}\n")
|
||||
self.state_store.update_step(step_id, command=command_display(command))
|
||||
try:
|
||||
self.runner.start(command, self.repository,
|
||||
build_runner_environment(os.environ, self.api_key_var.get(), self.proxy_var.get(), self.use_proxy_var.get()),
|
||||
self.state_store.workspace.log_path(f"{step_id}_{self.refaire_command_index}"))
|
||||
self._update_controls()
|
||||
return
|
||||
except (OSError, RuntimeError) as exc:
|
||||
self._append_console(f"Impossible de lancer la copie suivante : {exc}\n")
|
||||
status, return_code = "failed", 1
|
||||
self.pending_refaire_commands = []
|
||||
if step_id == "refaire_merge" and status == "success":
|
||||
for downstream in ("giving_names", "update_ods", "final_score", "personal_deploy", "personal_sent"):
|
||||
entry = self.state_store.step(downstream)
|
||||
if entry.get("status") in {"success", "detected", "skipped"}:
|
||||
self.state_store.update_step(downstream, status="stale")
|
||||
if step_id == "clean" and status == "success":
|
||||
self._append_console(
|
||||
f"\n[Terminé — code {return_code} — {STATUS_LABELS[status]}]\n"
|
||||
@@ -977,8 +1136,9 @@ class CopienatorApp(tk.Tk):
|
||||
self.console.configure(state="disabled")
|
||||
|
||||
def _update_controls(self) -> None:
|
||||
running = self.runner.running
|
||||
running = bool(self.active_step_id) or self.runner.running
|
||||
self.run_button.configure(state="disabled" if running or not self.current_step else "normal")
|
||||
self.skip_button.configure(state="normal" if not running and self.current_step and self.current_step.optional else "disabled")
|
||||
self.interrupt_button.configure(state="normal" if running else "disabled")
|
||||
self.force_button.configure(state="normal" if running else "disabled")
|
||||
self.send_button.configure(state="normal" if running else "disabled")
|
||||
@@ -990,7 +1150,7 @@ class CopienatorApp(tk.Tk):
|
||||
self._move_selection_from(self.current_step.id, delta)
|
||||
|
||||
def _move_selection_from(self, step_id: str, delta: int) -> None:
|
||||
ids = [step.id for step in self.steps]
|
||||
ids = self._progression_ids(step_id)
|
||||
try:
|
||||
index = ids.index(step_id)
|
||||
except ValueError:
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Selection and command planning for the optional redo workflow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import tkinter as tk
|
||||
from pathlib import Path
|
||||
from tkinter import ttk
|
||||
|
||||
from copienator import EvaluationWorkspace, read_json
|
||||
from copienator.utils import natural_key
|
||||
|
||||
SECTION = "Refaire des copies (facultatif)"
|
||||
ALL_LABELS = "Toute la copie"
|
||||
ALL_COPIES = "Toutes les copies"
|
||||
LAYOUTS = {
|
||||
"Automatique": "auto",
|
||||
"Par question (groupé)": "grouped",
|
||||
"Par copie": "copies",
|
||||
}
|
||||
|
||||
|
||||
def resolve_layout(
|
||||
selection: list[list], labels: list[str], choice: str = "auto"
|
||||
) -> str:
|
||||
if choice in {"grouped", "copies"}:
|
||||
return choice
|
||||
seen = set()
|
||||
for _name, selected in selection:
|
||||
current = set(selected or labels)
|
||||
if seen & current:
|
||||
return "grouped"
|
||||
seen.update(current)
|
||||
return "copies"
|
||||
|
||||
|
||||
def copies_with_answer(copies: dict[str, Path], label: str) -> list[str]:
|
||||
return [
|
||||
name
|
||||
for name, path in copies.items()
|
||||
if any(
|
||||
(path.with_suffix("") / f"{label}{suffix}.pdf").is_file()
|
||||
for suffix in ("", "_new")
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def available_copies(evaluation: Path) -> dict[str, Path]:
|
||||
return {
|
||||
path.stem: path
|
||||
for path in sorted((evaluation / "Copies").glob("Copie*.pdf"), key=natural_key)
|
||||
if re.fullmatch(r"Copie\d+", path.stem)
|
||||
}
|
||||
|
||||
|
||||
def validate_selection(
|
||||
entries: object, copies: dict[str, Path], labels: list[str]
|
||||
) -> list[list]:
|
||||
if not isinstance(entries, list) or not entries:
|
||||
raise ValueError("Ajoutez au moins une copie à refaire.")
|
||||
result = {}
|
||||
for entry in entries:
|
||||
if not isinstance(entry, list) or len(entry) != 2:
|
||||
raise ValueError("Sélection de copies invalide.")
|
||||
name, selected = entry
|
||||
if not isinstance(name, str) or name not in copies:
|
||||
raise ValueError(f"Copie introuvable : {name}")
|
||||
if not isinstance(selected, list) or any(
|
||||
not isinstance(label, str) or label not in labels for label in selected
|
||||
):
|
||||
raise ValueError(f"Question inconnue pour {name}. Reprenez la sélection.")
|
||||
if name in result:
|
||||
raise ValueError(f"Copie sélectionnée plusieurs fois : {name}")
|
||||
result[name] = sorted(set(selected), key=natural_key)
|
||||
return [[name, result[name]] for name in sorted(result, key=natural_key)]
|
||||
|
||||
|
||||
def load_selection(evaluation: Path) -> list[list]:
|
||||
return validate_selection(
|
||||
read_json(evaluation / "refaire.json"),
|
||||
available_copies(evaluation),
|
||||
EvaluationWorkspace(evaluation).read_labels(),
|
||||
)
|
||||
|
||||
|
||||
class RefaireSelection(ttk.Frame):
|
||||
def __init__(
|
||||
self,
|
||||
parent,
|
||||
evaluation: Path,
|
||||
draft: dict,
|
||||
directories: tuple[str, ...],
|
||||
preferred: str,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.columnconfigure(1, weight=1)
|
||||
self.copies = available_copies(evaluation)
|
||||
self.labels = (
|
||||
EvaluationWorkspace(evaluation).read_labels()
|
||||
if (evaluation / "labels").is_file()
|
||||
else []
|
||||
)
|
||||
self.entries = {}
|
||||
self.error = ""
|
||||
try:
|
||||
entries = draft.get("selection")
|
||||
if entries is None:
|
||||
entries = read_json(evaluation / "refaire.json", default=[])
|
||||
if entries:
|
||||
self.entries = dict(
|
||||
validate_selection(entries, self.copies, self.labels)
|
||||
)
|
||||
except (OSError, ValueError, TypeError) as exc:
|
||||
self.error = str(exc)
|
||||
self.copy_var = tk.StringVar(value=next(iter(self.copies), ""))
|
||||
self.label_var = tk.StringVar(value=ALL_LABELS)
|
||||
source = draft.get("annotation_dir", preferred)
|
||||
self.source_var = tk.StringVar(
|
||||
value=source if source in directories else preferred
|
||||
)
|
||||
ttk.Label(self, text="Copie").grid(row=0, column=0, sticky="w", padx=(0, 8))
|
||||
ttk.Combobox(
|
||||
self,
|
||||
textvariable=self.copy_var,
|
||||
values=(ALL_COPIES, *self.copies),
|
||||
state="readonly",
|
||||
width=12,
|
||||
).grid(row=0, column=1, sticky="ew")
|
||||
ttk.Label(self, text="Question").grid(row=1, column=0, sticky="w", pady=5)
|
||||
ttk.Combobox(
|
||||
self,
|
||||
textvariable=self.label_var,
|
||||
values=(ALL_LABELS, *self.labels),
|
||||
state="readonly",
|
||||
width=12,
|
||||
).grid(row=1, column=1, sticky="ew", pady=5)
|
||||
ttk.Button(self, text="+ Ajouter", command=self.add).grid(
|
||||
row=1, column=2, padx=(8, 0)
|
||||
)
|
||||
self.table = ttk.Treeview(
|
||||
self, columns=("labels",), height=4, selectmode="browse"
|
||||
)
|
||||
self.table.heading("#0", text="Copie")
|
||||
self.table.heading("labels", text="Questions à refaire")
|
||||
self.table.column("#0", width=100, stretch=False)
|
||||
self.table.column("labels", width=330)
|
||||
self.table.grid(row=4, column=0, columnspan=3, sticky="nsew")
|
||||
scrollbar = ttk.Scrollbar(self, orient="vertical", command=self.table.yview)
|
||||
scrollbar.grid(row=4, column=3, sticky="ns")
|
||||
self.table.configure(yscrollcommand=scrollbar.set)
|
||||
ttk.Button(
|
||||
self, text="Retirer la copie sélectionnée", command=self.remove
|
||||
).grid(row=5, column=0, columnspan=3, sticky="w", pady=5)
|
||||
ttk.Label(self, text="Passage principal").grid(
|
||||
row=3, column=0, sticky="w", padx=(0, 8)
|
||||
)
|
||||
ttk.Combobox(
|
||||
self,
|
||||
textvariable=self.source_var,
|
||||
values=directories,
|
||||
state="readonly",
|
||||
width=12,
|
||||
).grid(row=3, column=1, sticky="ew")
|
||||
layout = draft.get("layout", "auto")
|
||||
self.layout_var = tk.StringVar(
|
||||
value=next(
|
||||
(name for name, code in LAYOUTS.items() if code == layout),
|
||||
"Automatique",
|
||||
)
|
||||
)
|
||||
ttk.Label(self, text="PDF à vérifier").grid(row=2, column=0, sticky="w")
|
||||
ttk.Combobox(
|
||||
self,
|
||||
textvariable=self.layout_var,
|
||||
values=tuple(LAYOUTS),
|
||||
state="readonly",
|
||||
width=12,
|
||||
).grid(row=2, column=1, sticky="ew")
|
||||
self.message_var = tk.StringVar(
|
||||
value=self.error
|
||||
or "Choisissez « Toutes les copies » pour refaire une question dans toute la classe."
|
||||
)
|
||||
help_label = ttk.Label(
|
||||
self,
|
||||
textvariable=self.message_var,
|
||||
wraplength=500,
|
||||
)
|
||||
help_label.grid(row=6, column=0, columnspan=3, sticky="w", pady=5)
|
||||
self.bind(
|
||||
"<Configure>",
|
||||
lambda event: help_label.configure(wraplength=max(200, event.width - 10)),
|
||||
)
|
||||
self.refresh()
|
||||
|
||||
def refresh(self):
|
||||
self.table.delete(*self.table.get_children())
|
||||
for name in sorted(self.entries, key=natural_key):
|
||||
self.table.insert(
|
||||
"",
|
||||
"end",
|
||||
iid=name,
|
||||
text=name,
|
||||
values=(", ".join(self.entries[name]) or ALL_LABELS,),
|
||||
)
|
||||
|
||||
def add(self):
|
||||
name, label = self.copy_var.get(), self.label_var.get()
|
||||
if name not in (ALL_COPIES, *self.copies) or label not in (
|
||||
ALL_LABELS,
|
||||
*self.labels,
|
||||
):
|
||||
return
|
||||
names = (
|
||||
[name]
|
||||
if name != ALL_COPIES
|
||||
else list(self.copies)
|
||||
if label == ALL_LABELS
|
||||
else copies_with_answer(self.copies, label)
|
||||
)
|
||||
for copy_name in names:
|
||||
# Adding a question must not narrow a copy already selected in full.
|
||||
if label == ALL_LABELS:
|
||||
self.entries[copy_name] = []
|
||||
elif copy_name not in self.entries or self.entries[copy_name]:
|
||||
self.entries[copy_name] = sorted(
|
||||
set(self.entries.get(copy_name, [])) | {label}, key=natural_key
|
||||
)
|
||||
message = f"{len(names)} copie(s) ajoutée(s)."
|
||||
if name == ALL_COPIES and len(names) < len(self.copies):
|
||||
message += f" {len(self.copies) - len(names)} sans réponse découpée pour cette question."
|
||||
self.message_var.set(message)
|
||||
self.refresh()
|
||||
|
||||
def remove(self):
|
||||
for name in self.table.selection():
|
||||
self.entries.pop(name, None)
|
||||
self.refresh()
|
||||
|
||||
def values(self):
|
||||
return {
|
||||
"selection": [[name, labels] for name, labels in self.entries.items()],
|
||||
"annotation_dir": self.source_var.get(),
|
||||
"layout": LAYOUTS[self.layout_var.get()],
|
||||
}
|
||||
@@ -505,9 +505,46 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
requires=("Copies", "correction.json", "A Rendre"),
|
||||
),
|
||||
]
|
||||
steps[-1:-1] = build_refaire_workflow()
|
||||
return [step for step in steps if show_personal_steps or not step.personal]
|
||||
|
||||
|
||||
def build_refaire_workflow() -> list[StepDefinition]:
|
||||
from .refaire import SECTION
|
||||
|
||||
selection = StepDefinition(
|
||||
"refaire_selection", SECTION, "Choisir les copies et les questions",
|
||||
"Sélectionnez les copies et les questions à refaire, puis enregistrez la sélection. "
|
||||
"Le passage principal doit être terminé ; conservez ses annotations.",
|
||||
(CommandVariant("default", "Sélection", None, "manual"),),
|
||||
requires=("Copies", "labels", "correction.json"),
|
||||
)
|
||||
definitions = [
|
||||
("review", "Reprendre le découpage", "Vérifiez et ajustez les labels des copies sélectionnées. Chaque copie s’ouvre à son tour. Fermez la fenêtre pour passer à la suivante.", "review-labels", (), True),
|
||||
("split", "Redécouper les réponses", "À exécuter après une modification du découpage. Traite toutes les copies sélectionnées ; vérifiez les fichiers _new et _old en cas de résolution manuelle.", "split-answers", (), True),
|
||||
("correct", "Refaire la correction", "Relance la correction des seules questions sélectionnées. Peut être ignorée pour corriger manuellement les résultats.", "correct", ("--refaire",), True),
|
||||
("annotate", "Préparer les copies à vérifier", "Génère les questions sélectionnées avec des cases dans BRnot, par question ou par copie selon la sélection. Remplace le précédent passage dans BRnot.", "annotate-checks", ("--refaire", "--overwrite"), False),
|
||||
("export", "Exporter vers la tablette", "Exporte BRnot vers EXPORT_DIR. Retirez les anciens fichiers d’export avant le transfert.", "export", ("--refaire",), True),
|
||||
("tablet", "Vérifier sur la tablette", "Annotez les PDF exportés, puis placez les retours dans IMPORT_DIR sans changer leur nom (nom de groupe ou Copie01.pdf…). Retournez aussi les PDF sans modification. Retirez les anciens fichiers d’import.", None, (), False),
|
||||
("import", "Importer les copies vérifiées", "Importe les PDF retournés dans BRnot. Vous pouvez ignorer cette étape si les fichiers Concat_annotated.pdf y sont déjà en place.", "import", ("--refaire",), True),
|
||||
("merge", "Mettre à jour les copies finales", "Fusionne les questions refaites avec le reste de chaque copie dans le dossier du passage principal. Relancez ensuite la préparation de A Rendre, le calcul des notes et la diffusion.", "read-grouped", ("--refaire",), False),
|
||||
]
|
||||
steps = [selection]
|
||||
for suffix, title, description, program, flags, optional in definitions:
|
||||
arguments = (arg_target(),) if program else ()
|
||||
if suffix == "merge":
|
||||
arguments += (ArgumentSpec("annotation_dir", "Passage principal", "choice", "--annotation-dir", default="BGnot", choices=("BGnot", "Bnot", "Anot")),)
|
||||
requirements = ("refaire.json", "Copies", "labels", "correction.json")
|
||||
if suffix in {"export", "tablet", "import", "merge"}:
|
||||
requirements += ("BRnot",)
|
||||
steps.append(StepDefinition(
|
||||
f"refaire_{suffix}", SECTION, title, description,
|
||||
(CommandVariant("default", title, program, "python" if program else "manual", flags),),
|
||||
arguments=arguments, optional=optional, requires=requirements,
|
||||
))
|
||||
return steps
|
||||
|
||||
|
||||
def value_for_default(value: object, evaluation_arg: str) -> object:
|
||||
return evaluation_arg if value == EVALUATION else value
|
||||
|
||||
|
||||
Reference in New Issue
Block a user