Manual score modification step

This commit is contained in:
sebastien committed 2026-10-06 11:56:04 +02:00
1 parent 261a50a7d2
commit 7bc588e9a1
5 files changed
+383 -2

No files matched your search

+30 -2
View File
@@ -26,6 +26,7 @@ from copienator.rubrics import present_rubric_labels
from .diagnostics import collect_diagnostics
from .batch_monitor import BatchMonitor
from .giving_names import GivingNamesPanel, find_name_issues
from .scores import ScoreEditorPanel
from .notifications import notify_desktop
from .syncthing import SyncthingPanel
from .manual_resolution import ManualResolutionPanel
@@ -279,6 +280,7 @@ class CopienatorApp(tk.Tk):
self._rendering = False
self.refaire_panel: RefaireSelection | None = None
self.giving_names_panel: GivingNamesPanel | None = None
self.score_editor_panel: ScoreEditorPanel | None = None
self.complete_giving_names_button: ttk.Button | None = None
self.label_groups_button: ttk.Button | None = None
self.pending_refaire_commands: list[list[str]] = []
@@ -809,7 +811,7 @@ class CopienatorApp(tk.Tk):
if not step:
return
self._rendering = True
if step.id == "personal_read":
if step.id in {"personal_read", "edit_scores"}:
self.command_box.grid_remove()
else:
self.command_box.grid()
@@ -822,6 +824,7 @@ class CopienatorApp(tk.Tk):
"plotting",
"manual_resolution",
"giving_names",
"edit_scores",
"statement_persp",
}
self.console.configure(height=8 if expanded_form else 14)
@@ -835,6 +838,7 @@ class CopienatorApp(tk.Tk):
self.arg_vars.clear()
self.refaire_panel = None
self.giving_names_panel = None
self.score_editor_panel = None
self.complete_giving_names_button = None
self.label_groups_button = None
entry = self.state_store.step(step.id) if self.state_store.evaluation else {}
@@ -1050,6 +1054,12 @@ class CopienatorApp(tk.Tk):
)
row += 1
return row
if step.id == "edit_scores" and self.evaluation:
self.score_editor_panel = ScoreEditorPanel(
self.form, self.evaluation, self._score_changed
)
self.score_editor_panel.grid(row=row, column=0, columnspan=2, sticky="ew", pady=8)
row += 1
if step.id == "giving_names" and self.evaluation:
self.giving_names_panel = GivingNamesPanel(
self.form, self.evaluation
@@ -1658,7 +1668,9 @@ class CopienatorApp(tk.Tk):
if not self.current_step or not self.state_store.evaluation or self.active_step_id:
return
self._save_current_form()
self._invalidate_after_step(self.current_step, self._current_variant().id)
# Score edits already invalidate downstream steps when Set saves a change.
if self.current_step.id != "edit_scores":
self._invalidate_after_step(self.current_step, self._current_variant().id)
self.state_store.update_step(self.current_step.id, status=status)
history: dict[str, object] = {
"step": self.current_step.id,
@@ -1687,6 +1699,20 @@ class CopienatorApp(tk.Tk):
self._populate_tree()
self.info_var.set("Renommage validé — aucun fichier modifié.")
def _score_changed(self, copy_id: str, label: str, previous: str, current: str) -> None:
step = self.step_by_id["edit_scores"]
self._invalidate_after_step(step, "default")
self.state_store.update_step(step.id, status="detected")
self.state_store.add_history({
"step": step.id, "copy_id": copy_id, "label": label,
"previous_score": previous, "score": current, "manual": True,
})
# Refresh statuses without rebuilding the form between score edits.
for item in self.steps:
if self.tree.exists(item.id):
status = self._step_status(item)
self.tree.item(item.id, values=(STATUS_LABELS.get(status, status),))
def _complete_giving_names(self) -> None:
if (
not self.current_step
@@ -2091,6 +2117,8 @@ class CopienatorApp(tk.Tk):
self.complete_giving_names_button.configure(
state="disabled" if running else "normal"
)
if self.score_editor_panel is not None:
self.score_editor_panel.set_busy(running)
def _move_selection(self, delta: int) -> None:
if not self.current_step:
+135
View File
@@ -0,0 +1,135 @@
from __future__ import annotations
import re
import tkinter as tk
from decimal import Decimal, InvalidOperation
from pathlib import Path
from tkinter import messagebox, ttk
from copienator import EvaluationWorkspace, atomic_update_json, read_json
from copienator.utils import natural_key
from .giving_names import RETURN_FOLDER_PATTERN
def score_path_for_id(evaluation: Path, copy_id: str) -> Path:
identifier = copy_id.strip()
if not re.fullmatch(r"[0-9]+", identifier):
raise ValueError("Saisissez un numéro d’id.")
return_dir = EvaluationWorkspace(evaluation).return_dir
matches = []
if return_dir.is_dir():
for folder in return_dir.iterdir():
match = RETURN_FOLDER_PATTERN.fullmatch(folder.name)
if folder.is_dir() and not folder.is_symlink() and match and int(match["copy_id"]) == int(identifier):
matches.append(folder / "score.json")
if not matches:
raise FileNotFoundError(f"Aucun dossier dans A Rendre pour l’id {identifier}.")
if len(matches) > 1:
raise ValueError(f"Plusieurs dossiers dans A Rendre portent l’id {identifier}.")
if not matches[0].is_file():
raise FileNotFoundError(f"score.json introuvable : {matches[0]}")
return matches[0]
def read_scores(path: Path) -> dict:
scores = read_json(path)
if not isinstance(scores, dict):
raise ValueError(f"score.json doit contenir un objet JSON : {path}")
return scores
def set_score(path: Path, label: str, value: str) -> str:
try:
number = Decimal(value.strip().replace(",", "."))
except InvalidOperation:
raise ValueError("Le score doit être un nombre.") from None
if not number.is_finite() or number < 0:
raise ValueError("Le score doit être un nombre positif ou nul.")
previous = ""
def update(scores):
nonlocal previous
if not isinstance(scores, dict) or label not in scores:
raise ValueError(f"Label absent de score.json : {label}")
previous = str(scores[label])
scores[label] = str(number)
# A Rendre/score.json is often a symlink to the annotation score file.
# Resolve it before atomic replacement so the link continues to work.
atomic_update_json(path.resolve(strict=True), update, default_factory=dict)
return previous
class ScoreEditorPanel(ttk.Frame):
def __init__(self, parent, evaluation: Path, on_change):
super().__init__(parent)
self.evaluation = evaluation
self.on_change = on_change
self.current_path = None
self.busy = False
self.id_var = tk.StringVar()
self.label_var = tk.StringVar()
self.score_var = tk.StringVar()
self.status_var = tk.StringVar()
self.columnconfigure(1, weight=1)
ttk.Label(self, text="Id de copie").grid(row=0, column=0, sticky="w", padx=(0, 8), pady=4)
self.id_entry = ttk.Entry(self, textvariable=self.id_var)
self.id_entry.grid(row=0, column=1, sticky="ew", pady=4)
ttk.Label(self, text="Label").grid(row=1, column=0, sticky="w", padx=(0, 8), pady=4)
self.label_entry = ttk.Combobox(self, textvariable=self.label_var)
self.label_entry.grid(row=1, column=1, sticky="ew", pady=4)
ttk.Label(self, text="Score").grid(row=2, column=0, sticky="w", padx=(0, 8), pady=4)
self.score_entry = ttk.Entry(self, textvariable=self.score_var)
self.score_entry.grid(row=2, column=1, sticky="ew", pady=4)
self.set_button = ttk.Button(self, text="Set", command=self.save_score)
self.set_button.grid(row=2, column=2, padx=(8, 0), pady=4)
ttk.Label(self, textvariable=self.status_var, wraplength=650).grid(
row=3, column=0, columnspan=3, sticky="w", pady=8
)
self.id_var.trace_add("write", self.load_score)
self.label_var.trace_add("write", self.load_score)
self.score_entry.bind("<Return>", lambda _event: self.save_score())
self.load_score()
def load_score(self, *_args):
self.current_path = None
self.score_var.set("")
self.label_entry.configure(values=())
try:
path = score_path_for_id(self.evaluation, self.id_var.get())
scores = read_scores(path)
self.label_entry.configure(values=sorted(scores, key=natural_key))
label = self.label_var.get().strip()
if not label:
self.status_var.set("Choisissez ou saisissez un label.")
elif label not in scores:
self.status_var.set(f"Label absent de score.json : {label}")
else:
self.current_path = path
self.score_var.set(str(scores[label]))
self.status_var.set(path.parent.name)
except (OSError, ValueError) as exc:
self.status_var.set(str(exc))
self.set_busy(self.busy)
def set_busy(self, busy: bool):
self.busy = busy
self.id_entry.configure(state="disabled" if busy else "normal")
self.label_entry.configure(state="disabled" if busy else "normal")
state = "normal" if self.current_path is not None and not busy else "disabled"
self.score_entry.configure(state=state)
self.set_button.configure(state=state)
def save_score(self):
if self.current_path is None or self.busy:
return
label = self.label_var.get().strip()
try:
previous = set_score(self.current_path, label, self.score_var.get())
current = str(read_scores(self.current_path)[label])
self.score_var.set(current)
if previous != current:
self.on_change(self.id_var.get().strip(), label, previous, current)
self.status_var.set(f"Score enregistré : {self.current_path.parent.name} — {label} : {current}")
except (OSError, ValueError) as exc:
messagebox.showerror("Modification du score", str(exc))
+11
View File
@@ -679,6 +679,17 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
),
artifacts=("A Rendre",),
),
StepDefinition(
"edit_scores",
"Finalisation",
"Modifier des scores",
"Saisissez l’id de copie, puis choisissez ou saisissez un label. "
"Le score actuel est chargé automatiquement. Modifiez-le puis cliquez sur Set "
"pour l’enregistrer dans score.json. Cliquez sur « Marquer terminée » quand vous avez fini.",
(manual("default"),),
optional=True,
requires=("A Rendre",),
),
StepDefinition(
"personal_create",
"Étapes personnelles",