Refaire session.
This commit is contained in:
+35
-2
@@ -377,6 +377,39 @@ Ce parcours a sa propre progression : les boutons de navigation du
|
|||||||
passage principal ne l'ouvrent pas automatiquement. Après la fusion,
|
passage principal ne l'ouvrent pas automatiquement. Après la fusion,
|
||||||
les étapes de restitution déjà terminées sont marquées à revalider.
|
les étapes de restitution déjà terminées sont marquées à revalider.
|
||||||
|
|
||||||
|
Pour enchaîner les reprises, deux boutons sont disponibles dans ce parcours :
|
||||||
|
- =Nouvelle reprise= vide la sélection et remet les étapes de reprise à zéro.
|
||||||
|
- =Refaire la même sélection= conserve les copies et les questions du dernier
|
||||||
|
enregistrement, mais remet également les étapes de reprise à zéro.
|
||||||
|
Les choix du passage principal et de présentation des PDF sont conservés.
|
||||||
|
Enregistrer ensuite la sélection avant de poursuivre.
|
||||||
|
|
||||||
|
Attention : appeler =Nouvelle reprise= seulement après avoir importé les
|
||||||
|
résultats et exécuté =Mettre à jour les copies finales=. La même précaution
|
||||||
|
s'applique à =Refaire la même sélection=. Un avertissement est affiché avant
|
||||||
|
les deux actions, avec une mention supplémentaire si la fusion n'est pas
|
||||||
|
marquée réussie. Annuler conserve la reprise actuelle. Après une fusion
|
||||||
|
réussie, utiliser ces boutons pour recommencer, plutôt que modifier la
|
||||||
|
sélection du passage terminé.
|
||||||
|
|
||||||
|
Chaque nouveau passage utilise =Reprises/reprise-DATE-HEURE-ID/BRnot=.
|
||||||
|
Le passage précédent garde ses PDF, ses retours manuscrits, sa sélection
|
||||||
|
et une copie de la progression du GUI. Au premier changement de passage,
|
||||||
|
l'ancien =BRnot= à la racine est également copié dans =Reprises=.
|
||||||
|
Ces archives concernent les fichiers de vérification, pas un mécanisme
|
||||||
|
permettant d'annuler les modifications des copies finales.
|
||||||
|
Le fichier =refaire-session.json= désigne le passage actif ; les commandes
|
||||||
|
habituelles =--refaire= le suivent automatiquement. Sans ce fichier, le
|
||||||
|
fonctionnement historique dans =BRnot= à la racine reste disponible.
|
||||||
|
Dans la suite, =BRnot= désigne le dossier de la reprise active.
|
||||||
|
|
||||||
|
L'export d'un passage identifié utilise son propre sous-dossier dans
|
||||||
|
=EXPORT_DIR/Évaluation= et préfixe les noms des PDF par son identifiant.
|
||||||
|
Conserver les noms complets au retour et placer les PDF directement dans
|
||||||
|
=IMPORT_DIR=. L'import ignore les retours des autres passages ; aucun retour
|
||||||
|
du passage actif donne un résultat partiel. Ainsi, deux reprises de la même
|
||||||
|
question ne partagent pas les mêmes noms de fichiers exportés.
|
||||||
|
|
||||||
Ce flux fonctionne après =annotate-grouped= (=BGnot=),
|
Ce flux fonctionne après =annotate-grouped= (=BGnot=),
|
||||||
=annotate-checks= (=Bnot=) ou =annotate-simple= (=Anot=).
|
=annotate-checks= (=Bnot=) ou =annotate-simple= (=Anot=).
|
||||||
Terminer d'abord la lecture des annotations du passage principal
|
Terminer d'abord la lecture des annotations du passage principal
|
||||||
@@ -405,8 +438,8 @@ Conserver les dossiers d'annotation et leurs fichiers de référence.
|
|||||||
principal. Le mode groupé garde les identifiants des élèves et regroupe
|
principal. Le mode groupé garde les identifiants des élèves et regroupe
|
||||||
les réponses par label sans demander de modifier =label_groups=.
|
les réponses par label sans demander de modifier =label_groups=.
|
||||||
Cela ne nécessite pas d'avoir généré =Bnot= ou =BGnot= auparavant.
|
Cela ne nécessite pas d'avoir généré =Bnot= ou =BGnot= auparavant.
|
||||||
Attention : =--overwrite= remplace tout le précédent passage dans
|
Attention : =--overwrite= remplace le contenu du =BRnot= actif,
|
||||||
=BRnot=, y compris ses annotations manuscrites. Une génération incomplète
|
y compris ses annotations manuscrites, mais pas les autres reprises. Une génération incomplète
|
||||||
conserve l'ancien =BRnot=. Sans =--overwrite=, un =BRnot= existant est refusé.
|
conserve l'ancien =BRnot=. Sans =--overwrite=, un =BRnot= existant est refusé.
|
||||||
5. Vider les dossiers personnels d'export/import des anciens fichiers,
|
5. Vider les dossiers personnels d'export/import des anciens fichiers,
|
||||||
puis =python -m copienator export Interro --refaire=.
|
puis =python -m copienator export Interro --refaire=.
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import sys
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from copienator.configuration import EXPORT_DIR
|
|
||||||
from copienator import (
|
from copienator import (
|
||||||
EvaluationWorkspace,
|
EvaluationWorkspace,
|
||||||
ExitCode,
|
ExitCode,
|
||||||
@@ -11,6 +10,7 @@ from copienator import (
|
|||||||
execute,
|
execute,
|
||||||
workspace_from_args,
|
workspace_from_args,
|
||||||
)
|
)
|
||||||
|
from copienator.configuration import EXPORT_DIR
|
||||||
from copienator.platform import replace_with_link_or_copy
|
from copienator.platform import replace_with_link_or_copy
|
||||||
|
|
||||||
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
|
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
|
||||||
@@ -21,8 +21,12 @@ def export_directory(
|
|||||||
source_dir_name: str,
|
source_dir_name: str,
|
||||||
) -> ExitCode:
|
) -> ExitCode:
|
||||||
workspace.require_directories(source_dir_name)
|
workspace.require_directories(source_dir_name)
|
||||||
source_dir = workspace.root / source_dir_name
|
source_dir = workspace.annotation_dir("refaire") if source_dir_name == "BRnot" else workspace.root / source_dir_name
|
||||||
|
session_id = workspace.refaire_session_id if source_dir_name == "BRnot" else None
|
||||||
|
prefix = f"{session_id}__" if session_id else ""
|
||||||
sync_dir = Path(EXPORT_DIR).expanduser() / workspace.name
|
sync_dir = Path(EXPORT_DIR).expanduser() / workspace.name
|
||||||
|
if session_id:
|
||||||
|
sync_dir /= session_id
|
||||||
sync_dir.mkdir(parents=True, exist_ok=True)
|
sync_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
subdirs = [directory for directory in source_dir.iterdir() if directory.is_dir()]
|
subdirs = [directory for directory in source_dir.iterdir() if directory.is_dir()]
|
||||||
@@ -48,7 +52,7 @@ def export_directory(
|
|||||||
)
|
)
|
||||||
missing_outputs += 1
|
missing_outputs += 1
|
||||||
continue
|
continue
|
||||||
destination = sync_dir / f"{subdir.name}{concat_file.suffix.lower()}"
|
destination = sync_dir / f"{prefix}{subdir.name}{concat_file.suffix.lower()}"
|
||||||
method = replace_with_link_or_copy(concat_file, destination, prefer="hardlink")
|
method = replace_with_link_or_copy(concat_file, destination, prefer="hardlink")
|
||||||
print(f"Exported: {destination} ({method})")
|
print(f"Exported: {destination} ({method})")
|
||||||
return ExitCode.PARTIAL if missing_outputs else ExitCode.SUCCESS
|
return ExitCode.PARTIAL if missing_outputs else ExitCode.SUCCESS
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import sys
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from copienator.configuration import IMPORT_DIR
|
|
||||||
from copienator import (
|
from copienator import (
|
||||||
EvaluationWorkspace,
|
EvaluationWorkspace,
|
||||||
ExitCode,
|
ExitCode,
|
||||||
@@ -12,6 +11,7 @@ from copienator import (
|
|||||||
execute,
|
execute,
|
||||||
workspace_from_args,
|
workspace_from_args,
|
||||||
)
|
)
|
||||||
|
from copienator.configuration import IMPORT_DIR
|
||||||
|
|
||||||
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
|
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
|
||||||
|
|
||||||
@@ -23,7 +23,10 @@ def sync_annotated(
|
|||||||
import_dir: Path,
|
import_dir: Path,
|
||||||
) -> ExitCode:
|
) -> ExitCode:
|
||||||
workspace.require_directories(annotation_dir_name)
|
workspace.require_directories(annotation_dir_name)
|
||||||
annotation_dir = workspace.root / annotation_dir_name
|
annotation_dir = workspace.annotation_dir("refaire") if annotation_dir_name == "BRnot" else workspace.root / annotation_dir_name
|
||||||
|
session_id = workspace.refaire_session_id if annotation_dir_name == "BRnot" else None
|
||||||
|
prefix = f"{session_id}__" if session_id else ""
|
||||||
|
accepted = 0
|
||||||
annotated_dir = Path(import_dir).expanduser()
|
annotated_dir = Path(import_dir).expanduser()
|
||||||
if not annotated_dir.is_dir():
|
if not annotated_dir.is_dir():
|
||||||
print(f"Error: directory does not exist: {annotated_dir}", file=sys.stderr)
|
print(f"Error: directory does not exist: {annotated_dir}", file=sys.stderr)
|
||||||
@@ -39,7 +42,10 @@ def sync_annotated(
|
|||||||
key=lambda path: path.name.casefold(),
|
key=lambda path: path.name.casefold(),
|
||||||
)
|
)
|
||||||
for annotated_file in annotated_files:
|
for annotated_file in annotated_files:
|
||||||
target_subdir = annotation_dir / annotated_file.stem
|
if prefix and not annotated_file.stem.startswith(prefix):
|
||||||
|
print(f"Ignoring return from another pass: {annotated_file.name}")
|
||||||
|
continue
|
||||||
|
target_subdir = annotation_dir / annotated_file.stem.removeprefix(prefix)
|
||||||
|
|
||||||
if not target_subdir.is_dir():
|
if not target_subdir.is_dir():
|
||||||
print(f"Warning: directory not found: {target_subdir}", file=sys.stderr)
|
print(f"Warning: directory not found: {target_subdir}", file=sys.stderr)
|
||||||
@@ -49,6 +55,10 @@ def sync_annotated(
|
|||||||
dest_file = target_subdir / f"Concat_annotated{suffix}"
|
dest_file = target_subdir / f"Concat_annotated{suffix}"
|
||||||
print(f"Copying {annotated_file} to {dest_file}")
|
print(f"Copying {annotated_file} to {dest_file}")
|
||||||
shutil.copy2(annotated_file, dest_file)
|
shutil.copy2(annotated_file, dest_file)
|
||||||
|
accepted += 1
|
||||||
|
if prefix and not accepted:
|
||||||
|
print(f"No returns for the active pass {session_id} were imported.")
|
||||||
|
return ExitCode.PARTIAL
|
||||||
return ExitCode.PARTIAL if missing_targets else ExitCode.SUCCESS
|
return ExitCode.PARTIAL if missing_targets else ExitCode.SUCCESS
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+20
-1
@@ -165,7 +165,26 @@ class EvaluationWorkspace:
|
|||||||
def return_dir(self) -> Path:
|
def return_dir(self) -> Path:
|
||||||
return self.root / "A Rendre"
|
return self.root / "A Rendre"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def refaire_session_id(self) -> str | None:
|
||||||
|
from .json_io import read_json
|
||||||
|
|
||||||
|
session = read_json(self.root / "refaire-session.json", default=None)
|
||||||
|
if session is None:
|
||||||
|
return None
|
||||||
|
ident = session.get("id") if isinstance(session, dict) else None
|
||||||
|
if not isinstance(ident, str) or not re.fullmatch(r"reprise-[0-9]{8}-[0-9]{6}-[a-f0-9]{8}", ident):
|
||||||
|
raise ValueError("Invalid refaire-session.json")
|
||||||
|
return ident
|
||||||
|
|
||||||
|
@property
|
||||||
|
def refaire_session_dir(self) -> Path | None:
|
||||||
|
ident = self.refaire_session_id
|
||||||
|
return self.root / "Reprises" / ident if ident else None
|
||||||
|
|
||||||
def annotation_dir(self, mode: str) -> Path:
|
def annotation_dir(self, mode: str) -> Path:
|
||||||
|
if mode == "refaire" and self.refaire_session_dir is not None:
|
||||||
|
return self.refaire_session_dir / "BRnot"
|
||||||
directories = {
|
directories = {
|
||||||
"simple": "Anot",
|
"simple": "Anot",
|
||||||
"checks": "Bnot",
|
"checks": "Bnot",
|
||||||
@@ -215,7 +234,7 @@ class EvaluationWorkspace:
|
|||||||
missing = [
|
missing = [
|
||||||
relative_path
|
relative_path
|
||||||
for relative_path in relative_paths
|
for relative_path in relative_paths
|
||||||
if not (self.root / relative_path).is_dir()
|
if not (self.annotation_dir("refaire") if relative_path == "BRnot" else self.root / relative_path).is_dir()
|
||||||
]
|
]
|
||||||
if missing:
|
if missing:
|
||||||
raise WorkspaceValidationError(self.root, missing)
|
raise WorkspaceValidationError(self.root, missing)
|
||||||
|
|||||||
+60
-1
@@ -25,6 +25,7 @@ from .refaire import (
|
|||||||
resolve_layout,
|
resolve_layout,
|
||||||
validate_selection,
|
validate_selection,
|
||||||
)
|
)
|
||||||
|
from .refaire_sessions import RESTART_WARNING, begin_pass
|
||||||
from .runner import ProcessRunner
|
from .runner import ProcessRunner
|
||||||
from .state import StateStore
|
from .state import StateStore
|
||||||
from .workflow import (
|
from .workflow import (
|
||||||
@@ -470,7 +471,8 @@ class CopienatorApp(tk.Tk):
|
|||||||
if any(char in pattern for char in "*?["):
|
if any(char in pattern for char in "*?["):
|
||||||
exists = next(evaluation.glob(pattern), None) is not None
|
exists = next(evaluation.glob(pattern), None) is not None
|
||||||
else:
|
else:
|
||||||
exists = (evaluation / pattern).exists()
|
path = EvaluationWorkspace(evaluation).annotation_dir("refaire") if pattern == "BRnot" else evaluation / pattern
|
||||||
|
exists = path.exists()
|
||||||
if not exists:
|
if not exists:
|
||||||
missing.append(pattern)
|
missing.append(pattern)
|
||||||
return missing
|
return missing
|
||||||
@@ -635,6 +637,7 @@ class CopienatorApp(tk.Tk):
|
|||||||
evaluation = self.evaluation
|
evaluation = self.evaluation
|
||||||
if step.id in {"refaire_selection", "refaire_correct"}:
|
if step.id in {"refaire_selection", "refaire_correct"}:
|
||||||
row = self._correction_folder_buttons(row)
|
row = self._correction_folder_buttons(row)
|
||||||
|
row = self._refaire_restart_controls(row)
|
||||||
if step.id == "refaire_selection" and evaluation:
|
if step.id == "refaire_selection" and evaluation:
|
||||||
choices, preferred = self._annotation_directory_choices("export")
|
choices, preferred = self._annotation_directory_choices("export")
|
||||||
draft = self.state_store.step(step.id).get("values", {})
|
draft = self.state_store.step(step.id).get("values", {})
|
||||||
@@ -699,6 +702,50 @@ class CopienatorApp(tk.Tk):
|
|||||||
row += 1
|
row += 1
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
def _refaire_restart_controls(self, row: int) -> int:
|
||||||
|
controls = ttk.Frame(self.form)
|
||||||
|
controls.grid(row=row, column=0, columnspan=2, sticky="w", pady=(0, 5))
|
||||||
|
running = bool(self.active_step_id) or self.runner.running
|
||||||
|
ttk.Button(controls, text="Nouvelle reprise", command=lambda: self._start_refaire_pass(False),
|
||||||
|
state="disabled" if running else "normal").pack(side="left", padx=(0, 6))
|
||||||
|
ttk.Button(controls, text="Refaire la même sélection", command=lambda: self._start_refaire_pass(True),
|
||||||
|
state="disabled" if running else "normal").pack(side="left")
|
||||||
|
ttk.Label(self.form, text="« Nouvelle reprise » : à utiliser après avoir importé les résultats et mis à jour les copies finales.",
|
||||||
|
wraplength=580, justify="left").grid(row=row + 1, column=0, columnspan=2, sticky="w", pady=(0, 6))
|
||||||
|
return row + 2
|
||||||
|
|
||||||
|
def _start_refaire_pass(self, keep_selection: bool) -> None:
|
||||||
|
workspace = self.state_store.workspace
|
||||||
|
if workspace is None:
|
||||||
|
messagebox.showerror("Évaluation absente", "Chargez d’abord une évaluation.")
|
||||||
|
return
|
||||||
|
if self.active_step_id or self.runner.running:
|
||||||
|
messagebox.showwarning("Traitement en cours", "Attendez la fin du traitement avant de commencer une reprise.")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if keep_selection:
|
||||||
|
load_selection(workspace.root)
|
||||||
|
title = "Refaire la même sélection" if keep_selection else "Nouvelle reprise"
|
||||||
|
message = RESTART_WARNING
|
||||||
|
if self.state_store.step("refaire_merge").get("status") != "success":
|
||||||
|
message += "\n\nLa reprise actuelle n’est pas marquée comme fusionnée. Les résultats non fusionnés ne seront pas intégrés aux copies finales."
|
||||||
|
message += "\n\nLes fichiers de la reprise précédente seront conservés. Continuer ?"
|
||||||
|
if not messagebox.askyesno(title, message, icon="warning", default="no"):
|
||||||
|
return
|
||||||
|
self._save_current_form()
|
||||||
|
updated, ident = begin_pass(workspace, self.state_store.data, keep_selection=keep_selection)
|
||||||
|
self.state_store.data = updated
|
||||||
|
# Do not save the old form over the fresh pass during navigation.
|
||||||
|
self.current_step = None
|
||||||
|
self.refaire_panel = None
|
||||||
|
self.arg_vars.clear()
|
||||||
|
self._populate_tree()
|
||||||
|
self.tree.selection_set("refaire_selection")
|
||||||
|
self.tree.see("refaire_selection")
|
||||||
|
self.info_var.set(f"Nouvelle reprise : {ident}. Enregistrez la sélection pour continuer.")
|
||||||
|
except (OSError, ValueError, TypeError) as exc:
|
||||||
|
messagebox.showerror("Nouvelle reprise impossible", str(exc))
|
||||||
|
|
||||||
def _correction_folder_buttons(self, row: int) -> int:
|
def _correction_folder_buttons(self, row: int) -> int:
|
||||||
buttons = ttk.Frame(self.form)
|
buttons = ttk.Frame(self.form)
|
||||||
buttons.grid(row=row, column=0, columnspan=2, sticky="w", pady=(0, 6))
|
buttons.grid(row=row, column=0, columnspan=2, sticky="w", pady=(0, 6))
|
||||||
@@ -804,11 +851,23 @@ class CopienatorApp(tk.Tk):
|
|||||||
if not self.refaire_panel or not self.evaluation:
|
if not self.refaire_panel or not self.evaluation:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
if self.state_store.step("refaire_merge").get("status") == "success":
|
||||||
|
messagebox.showinfo("Reprise terminée", "Utilisez « Nouvelle reprise » ou « Refaire la même sélection » pour commencer un autre passage.")
|
||||||
|
return
|
||||||
values = self.refaire_panel.values()
|
values = self.refaire_panel.values()
|
||||||
entries = validate_selection(values["selection"], available_copies(self.evaluation), EvaluationWorkspace(self.evaluation).read_labels())
|
entries = validate_selection(values["selection"], available_copies(self.evaluation), EvaluationWorkspace(self.evaluation).read_labels())
|
||||||
source = values["annotation_dir"]
|
source = values["annotation_dir"]
|
||||||
if source not in detected_annotation_directories(self.evaluation):
|
if source not in detected_annotation_directories(self.evaluation):
|
||||||
raise ValueError("Choisissez un dossier existant pour le passage principal.")
|
raise ValueError("Choisissez un dossier existant pour le passage principal.")
|
||||||
|
workspace = EvaluationWorkspace(self.evaluation)
|
||||||
|
output = workspace.annotation_dir("refaire")
|
||||||
|
if output.is_dir() and any(output.iterdir()) and workspace.refaire_file.is_file():
|
||||||
|
from copienator import read_json
|
||||||
|
if read_json(workspace.refaire_file) != entries:
|
||||||
|
raise ValueError("Des PDF de vérification existent déjà. Importez et fusionnez leurs résultats, puis utilisez « Nouvelle reprise » pour changer de sélection.")
|
||||||
|
if workspace.refaire_session_dir is not None:
|
||||||
|
atomic_write_json(workspace.refaire_session_dir / "refaire.json", entries)
|
||||||
|
atomic_write_json(workspace.refaire_session_dir / "session.json", {"id": workspace.refaire_session_id, "values": values})
|
||||||
atomic_write_json(self.evaluation / "refaire.json", entries)
|
atomic_write_json(self.evaluation / "refaire.json", entries)
|
||||||
self._mark_step("success")
|
self._mark_step("success")
|
||||||
self._move_selection_from("refaire_selection", 1)
|
self._move_selection_from("refaire_selection", 1)
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Preserve completed redo passes and activate a fresh working directory."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import shutil
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from copienator import EvaluationWorkspace, atomic_write_json, read_json
|
||||||
|
from copienator.filesystem import staged_files
|
||||||
|
|
||||||
|
RESTART_WARNING = (
|
||||||
|
"Appelez « Nouvelle reprise » seulement après avoir importé les résultats "
|
||||||
|
"de la reprise en cours et exécuté « Mettre à jour les copies finales ». "
|
||||||
|
"Cette consigne s’applique aussi à « Refaire la même sélection »."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _identifier() -> str:
|
||||||
|
return f"reprise-{datetime.now().astimezone():%Y%m%d-%H%M%S}-{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
|
||||||
|
def begin_pass(
|
||||||
|
workspace: EvaluationWorkspace,
|
||||||
|
state: dict[str, Any],
|
||||||
|
*,
|
||||||
|
keep_selection: bool,
|
||||||
|
) -> tuple[dict[str, Any], str]:
|
||||||
|
"""Activate a pass atomically with its selection and reset GUI state.
|
||||||
|
|
||||||
|
Existing review files remain in place. Legacy BRnot is copied once into
|
||||||
|
the archive; failures before activation leave the old pass active.
|
||||||
|
"""
|
||||||
|
selection = read_json(workspace.refaire_file, default=[])
|
||||||
|
previous = workspace.refaire_session_dir
|
||||||
|
if previous is None and (
|
||||||
|
workspace.refaire_file.exists() or workspace.annotation_dir("refaire").exists()
|
||||||
|
):
|
||||||
|
previous = workspace.root / "Reprises" / _identifier()
|
||||||
|
previous.mkdir(parents=True)
|
||||||
|
legacy = workspace.annotation_dir("refaire")
|
||||||
|
if legacy.is_dir():
|
||||||
|
shutil.copytree(legacy, previous / "BRnot")
|
||||||
|
if previous is not None:
|
||||||
|
atomic_write_json(previous / "refaire.json", selection)
|
||||||
|
atomic_write_json(
|
||||||
|
previous / "progression.json",
|
||||||
|
{
|
||||||
|
name: entry
|
||||||
|
for name, entry in state.get("steps", {}).items()
|
||||||
|
if name.startswith("refaire_")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
ident = _identifier()
|
||||||
|
directory = workspace.root / "Reprises" / ident
|
||||||
|
directory.mkdir(parents=True)
|
||||||
|
new_selection = selection if keep_selection else []
|
||||||
|
updated = copy.deepcopy(state)
|
||||||
|
values = dict(
|
||||||
|
updated.get("steps", {}).get("refaire_selection", {}).get("values", {})
|
||||||
|
)
|
||||||
|
values["selection"] = new_selection
|
||||||
|
updated["steps"] = {
|
||||||
|
name: entry
|
||||||
|
for name, entry in updated.get("steps", {}).items()
|
||||||
|
if not name.startswith("refaire_")
|
||||||
|
}
|
||||||
|
updated["steps"]["refaire_selection"] = {"values": values}
|
||||||
|
updated.setdefault("history", []).append(
|
||||||
|
{
|
||||||
|
"step": "refaire_selection",
|
||||||
|
"action": "repeat" if keep_selection else "new",
|
||||||
|
"session": ident,
|
||||||
|
"timestamp": datetime.now().astimezone().isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
atomic_write_json(directory / "refaire.json", new_selection)
|
||||||
|
atomic_write_json(directory / "session.json", {"id": ident, "values": values})
|
||||||
|
with staged_files(workspace.root) as staging:
|
||||||
|
atomic_write_json(staging / workspace.refaire_file.name, new_selection)
|
||||||
|
atomic_write_json(staging / workspace.gui_state_file.name, updated)
|
||||||
|
atomic_write_json(staging / "refaire-session.json", {"id": ident})
|
||||||
|
return updated, ident
|
||||||
@@ -194,6 +194,49 @@ class RefaireGuiTests(unittest.TestCase):
|
|||||||
[self.root / "Sol", self.root / "Persp"],
|
[self.root / "Sol", self.root / "Persp"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_restart_actions_warn_reset_and_preserve_or_clear_selection(self):
|
||||||
|
self.save_selection()
|
||||||
|
self.app.state_store.update_step("refaire_merge", status="success")
|
||||||
|
self.app.state_store.update_step("annotation", status="success")
|
||||||
|
selection = read_json(self.root / "refaire.json")
|
||||||
|
with patch(
|
||||||
|
"copienator_gui.app.messagebox.askyesno", return_value=False
|
||||||
|
) as confirm:
|
||||||
|
self.app._start_refaire_pass(False)
|
||||||
|
self.assertIn("importé", confirm.call_args.args[1])
|
||||||
|
self.assertIn("Mettre à jour", confirm.call_args.args[1])
|
||||||
|
self.assertEqual(read_json(self.root / "refaire.json"), selection)
|
||||||
|
with patch("copienator_gui.app.messagebox.askyesno", return_value=True):
|
||||||
|
self.app._start_refaire_pass(True)
|
||||||
|
self.app.update()
|
||||||
|
self.assertEqual(self.app.current_step.id, "refaire_selection")
|
||||||
|
self.assertEqual(self.app.refaire_panel.values()["selection"], selection)
|
||||||
|
self.assertIsNone(self.app.state_store.step("refaire_merge").get("status"))
|
||||||
|
self.assertEqual(self.app.state_store.step("annotation")["status"], "success")
|
||||||
|
first = self.app.state_store.workspace.refaire_session_id
|
||||||
|
with patch(
|
||||||
|
"copienator_gui.app.messagebox.askyesno", return_value=True
|
||||||
|
) as confirm:
|
||||||
|
self.app._start_refaire_pass(False)
|
||||||
|
self.app.update()
|
||||||
|
self.assertIn("n’est pas marquée comme fusionnée", confirm.call_args.args[1])
|
||||||
|
self.assertEqual(self.app.refaire_panel.entries, {})
|
||||||
|
self.assertEqual(read_json(self.root / "refaire.json"), [])
|
||||||
|
self.assertNotEqual(self.app.state_store.workspace.refaire_session_id, first)
|
||||||
|
self.assertEqual(self.app.refaire_panel.source_var.get(), "Anot")
|
||||||
|
|
||||||
|
def test_restart_is_blocked_while_a_command_is_active(self):
|
||||||
|
self.save_selection()
|
||||||
|
self.app.active_step_id = "refaire_correct"
|
||||||
|
with (
|
||||||
|
patch("copienator_gui.app.messagebox.showwarning") as warning,
|
||||||
|
patch("copienator_gui.app.messagebox.askyesno") as confirm,
|
||||||
|
):
|
||||||
|
self.app._start_refaire_pass(False)
|
||||||
|
warning.assert_called_once()
|
||||||
|
confirm.assert_not_called()
|
||||||
|
self.assertIsNone(self.app.state_store.workspace.refaire_session_id)
|
||||||
|
|
||||||
def test_small_window_keeps_selection_accessible_by_scrolling(self):
|
def test_small_window_keeps_selection_accessible_by_scrolling(self):
|
||||||
self.select("refaire_selection")
|
self.select("refaire_selection")
|
||||||
self.app.geometry("900x640")
|
self.app.geometry("900x640")
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from copienator import EvaluationWorkspace, ExitCode, atomic_write_json, read_json
|
||||||
|
from copienator.annotation_data import AnnotationLoadResult
|
||||||
|
from copienator.commands import annotating_with_checks as checks
|
||||||
|
from copienator.commands import export, import_annotations
|
||||||
|
from copienator.commands import reading_grouped_annotations as reader
|
||||||
|
from copienator_gui.refaire_sessions import begin_pass
|
||||||
|
|
||||||
|
|
||||||
|
class RefaireSessionTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temp.name) / "Exam"
|
||||||
|
self.root.mkdir()
|
||||||
|
self.workspace = EvaluationWorkspace(self.root)
|
||||||
|
atomic_write_json(self.workspace.refaire_file, [["Copie01", ["Ex 1"]]])
|
||||||
|
(self.root / "BRnot/Copie01").mkdir(parents=True)
|
||||||
|
(self.root / "BRnot/Copie01/Concat_annotated.pdf").write_bytes(b"legacy return")
|
||||||
|
self.state = {
|
||||||
|
"steps": {
|
||||||
|
"annotation": {"status": "success"},
|
||||||
|
"refaire_selection": {
|
||||||
|
"status": "success",
|
||||||
|
"values": {
|
||||||
|
"selection": [["Copie01", ["Ex 1"]]],
|
||||||
|
"annotation_dir": "Anot",
|
||||||
|
"layout": "grouped",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"refaire_merge": {"status": "success"},
|
||||||
|
},
|
||||||
|
"history": [],
|
||||||
|
}
|
||||||
|
atomic_write_json(self.workspace.gui_state_file, self.state)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.temp.cleanup()
|
||||||
|
|
||||||
|
def test_new_then_repeat_preserve_each_pass_and_reset_only_redo_progress(self):
|
||||||
|
state, first = begin_pass(self.workspace, self.state, keep_selection=False)
|
||||||
|
self.assertEqual(
|
||||||
|
state["steps"]["annotation"], self.state["steps"]["annotation"]
|
||||||
|
)
|
||||||
|
self.assertNotIn("refaire_merge", state["steps"])
|
||||||
|
self.assertEqual(state["steps"]["refaire_selection"]["values"]["selection"], [])
|
||||||
|
self.assertEqual(
|
||||||
|
state["steps"]["refaire_selection"]["values"]["layout"], "grouped"
|
||||||
|
)
|
||||||
|
self.assertEqual(read_json(self.workspace.refaire_file), [])
|
||||||
|
archives = [
|
||||||
|
path for path in (self.root / "Reprises").iterdir() if path.name != first
|
||||||
|
]
|
||||||
|
self.assertEqual(len(archives), 1)
|
||||||
|
self.assertEqual(
|
||||||
|
(archives[0] / "BRnot/Copie01/Concat_annotated.pdf").read_bytes(),
|
||||||
|
b"legacy return",
|
||||||
|
)
|
||||||
|
selection = [["Copie02", ["Ex 2"]]]
|
||||||
|
atomic_write_json(self.workspace.refaire_file, selection)
|
||||||
|
first_output = self.workspace.annotation_dir("refaire")
|
||||||
|
first_output.mkdir(parents=True)
|
||||||
|
(first_output / "review.pdf").write_bytes(b"first review")
|
||||||
|
state["steps"]["refaire_merge"] = {"status": "success"}
|
||||||
|
updated, second = begin_pass(self.workspace, state, keep_selection=True)
|
||||||
|
self.assertNotEqual(first, second)
|
||||||
|
self.assertEqual(read_json(self.workspace.refaire_file), selection)
|
||||||
|
self.assertEqual(
|
||||||
|
updated["steps"]["refaire_selection"]["values"]["selection"], selection
|
||||||
|
)
|
||||||
|
self.assertEqual((first_output / "review.pdf").read_bytes(), b"first review")
|
||||||
|
self.assertEqual(read_json(first_output.parent / "refaire.json"), selection)
|
||||||
|
self.assertNotIn("status", updated["steps"]["refaire_selection"])
|
||||||
|
self.assertEqual(
|
||||||
|
self.workspace.annotation_dir("refaire"),
|
||||||
|
self.root / "Reprises" / second / "BRnot",
|
||||||
|
)
|
||||||
|
reloaded = EvaluationWorkspace(self.root)
|
||||||
|
self.assertEqual(reloaded.refaire_session_id, second)
|
||||||
|
|
||||||
|
def test_failed_activation_leaves_current_pass_and_state_unchanged(self):
|
||||||
|
before = copy.deepcopy(self.state)
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"copienator_gui.refaire_sessions.staged_files",
|
||||||
|
side_effect=OSError("disk unavailable"),
|
||||||
|
),
|
||||||
|
self.assertRaises(OSError),
|
||||||
|
):
|
||||||
|
begin_pass(self.workspace, self.state, keep_selection=False)
|
||||||
|
self.assertIsNone(self.workspace.refaire_session_id)
|
||||||
|
self.assertEqual(read_json(self.workspace.gui_state_file), before)
|
||||||
|
self.assertEqual(
|
||||||
|
read_json(self.workspace.refaire_file), [["Copie01", ["Ex 1"]]]
|
||||||
|
)
|
||||||
|
self.assertEqual(self.state, before)
|
||||||
|
self.assertEqual(
|
||||||
|
(self.root / "BRnot/Copie01/Concat_annotated.pdf").read_bytes(),
|
||||||
|
b"legacy return",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_exports_are_unique_and_old_returns_cannot_enter_new_pass(self):
|
||||||
|
export_root = Path(self.temp.name) / "Export"
|
||||||
|
import_root = Path(self.temp.name) / "Import"
|
||||||
|
import_root.mkdir()
|
||||||
|
state, first = begin_pass(self.workspace, self.state, keep_selection=True)
|
||||||
|
for expected_id in (first, None):
|
||||||
|
if expected_id is None:
|
||||||
|
state, second = begin_pass(self.workspace, state, keep_selection=True)
|
||||||
|
expected_id = second
|
||||||
|
output = self.workspace.annotation_dir("refaire") / "Ex 1 G1"
|
||||||
|
output.mkdir(parents=True)
|
||||||
|
(output / "Concat.pdf").write_bytes(expected_id.encode())
|
||||||
|
with patch.object(export, "EXPORT_DIR", export_root):
|
||||||
|
self.assertEqual(
|
||||||
|
export.run(self.workspace, refaire=True), ExitCode.SUCCESS
|
||||||
|
)
|
||||||
|
exported = (
|
||||||
|
export_root / "Exam" / expected_id / f"{expected_id}__Ex 1 G1.pdf"
|
||||||
|
)
|
||||||
|
self.assertEqual(exported.read_bytes(), expected_id.encode())
|
||||||
|
if expected_id == first:
|
||||||
|
(import_root / exported.name).write_bytes(b"old return")
|
||||||
|
current = self.workspace.annotation_dir("refaire") / "Ex 1 G1"
|
||||||
|
with patch.object(import_annotations, "IMPORT_DIR", import_root):
|
||||||
|
self.assertEqual(
|
||||||
|
import_annotations.run(self.workspace, refaire=True), ExitCode.PARTIAL
|
||||||
|
)
|
||||||
|
self.assertFalse((current / "Concat_annotated.pdf").exists())
|
||||||
|
(import_root / f"{second}__Ex 1 G1.pdf").write_bytes(b"new return")
|
||||||
|
self.assertEqual(
|
||||||
|
import_annotations.run(self.workspace, refaire=True), ExitCode.SUCCESS
|
||||||
|
)
|
||||||
|
self.assertEqual((current / "Concat_annotated.pdf").read_bytes(), b"new return")
|
||||||
|
self.workspace.require_directories("BRnot")
|
||||||
|
|
||||||
|
def test_generation_and_merge_follow_the_active_pass(self):
|
||||||
|
for directory in ("Copies", "Par label", "Anot"):
|
||||||
|
(self.root / directory).mkdir()
|
||||||
|
(self.root / "labels").write_text("Ex 1\n")
|
||||||
|
atomic_write_json(self.root / "correction.json", {})
|
||||||
|
answer = self.root / "answer.pdf"
|
||||||
|
answer.touch()
|
||||||
|
loaded = AnnotationLoadResult(
|
||||||
|
{
|
||||||
|
"01": {
|
||||||
|
"Ex 1": {
|
||||||
|
"pdf_path": answer,
|
||||||
|
"result": {"score": 2, "feedback": []},
|
||||||
|
"coordinates": (0, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
begin_pass(self.workspace, self.state, keep_selection=True)
|
||||||
|
with (
|
||||||
|
patch.object(checks, "load_annotation_data", return_value=loaded),
|
||||||
|
patch.object(reader, "load_annotation_data", return_value=loaded),
|
||||||
|
patch.object(
|
||||||
|
checks.annotating,
|
||||||
|
"make_base_image",
|
||||||
|
return_value=(Image.new("RGB", (100, 100), "white"), 0, 0),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
checks.annotating,
|
||||||
|
"compose_label_image",
|
||||||
|
return_value=(Image.new("RGB", (100, 100), "white"), 0),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
self.assertEqual(
|
||||||
|
checks.run(self.workspace, self.root, refaire=True, overwrite=True),
|
||||||
|
ExitCode.SUCCESS,
|
||||||
|
)
|
||||||
|
output = self.workspace.annotation_dir("refaire") / "Copie01"
|
||||||
|
shutil.copy2(output / "Concat.pdf", output / "Concat_annotated.pdf")
|
||||||
|
self.assertEqual(
|
||||||
|
reader.run(self.workspace, refaire=True, annotation_dir="Anot"),
|
||||||
|
ExitCode.SUCCESS,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
read_json(self.root / "Anot/Copie01/score.json"), {"Ex 1": "2"}
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
(self.root / "BRnot/Copie01/Concat_annotated.pdf").read_bytes(),
|
||||||
|
b"legacy return",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_invalid_session_id_cannot_escape_workspace(self):
|
||||||
|
atomic_write_json(self.root / "refaire-session.json", {"id": "../elsewhere"})
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.workspace.annotation_dir("refaire")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user