Refaire fixes and GUI support
This commit is contained in:
@@ -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()],
|
||||
}
|
||||
Reference in New Issue
Block a user