Files
Copies/copienator_gui/scores.py
T

167 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 copienator.custom_remarks import remarks_for_copy, set_remark
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, on_remark_change=None):
super().__init__(parent)
self.evaluation = evaluation
self.on_change = on_change
self.on_remark_change = on_remark_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="Enregistrer le score", command=self.save_score)
self.set_button.grid(row=2, column=2, padx=(8, 0), pady=4)
ttk.Label(self, text="Remarque personnelle").grid(row=3, column=0, sticky="nw", pady=4)
self.remark_entry = tk.Text(self, height=4, width=45, wrap="word", undo=True)
self.remark_entry.grid(row=3, column=1, sticky="ew", pady=4)
self.remark_button = ttk.Button(self, text="Enregistrer la remarque", command=self.save_remark)
self.remark_button.grid(row=3, column=2, padx=(8, 0), pady=4)
ttk.Label(self, text="Videz le champ puis enregistrez pour supprimer votre remarque.").grid(
row=4, column=1, columnspan=2, sticky="w")
ttk.Label(self, textvariable=self.status_var, wraplength=650).grid(
row=5, 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.remark_entry.configure(state="normal")
self.remark_entry.delete("1.0", "end")
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:
remark = remarks_for_copy(self.evaluation, self.id_var.get()).get(label, "")
self.remark_entry.insert("1.0", remark)
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.remark_entry.edit_reset()
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)
self.remark_entry.configure(state=state)
self.remark_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}. Régénération nécessaire.")
except (OSError, ValueError) as exc:
messagebox.showerror("Modification du score", str(exc))
def save_remark(self):
if self.current_path is None or self.busy:
return
label = self.label_var.get().strip()
try:
if label not in read_scores(self.current_path):
raise ValueError(f"Label absent de score.json : {label}")
current = self.remark_entry.get("1.0", "end-1c").strip()
previous = set_remark(self.evaluation, self.id_var.get(), label, current)
if previous != current and self.on_remark_change:
self.on_remark_change(self.id_var.get().strip(), label, previous, current)
self.status_var.set("Remarque enregistrée — régénération nécessaire dans « Lire les annotations manuscrites ».")
except (OSError, ValueError) as exc:
messagebox.showerror("Modification de la remarque", str(exc))