"""Reversible removal of one stable copy ID and its working artifacts.""" from __future__ import annotations import io import json import os import re import shutil import uuid from dataclasses import dataclass from datetime import datetime from pathlib import Path from PIL import Image, ImageDraw from copienator import atomic_write_bytes, read_json from copienator.evaluation_lock import EvaluationLock @dataclass(frozen=True) class RemovalPlan: root: Path copy: str removed: tuple[Path, ...] updated: dict[Path, bytes] def available_copies(root: Path) -> list[str]: names = set() for folder in (root, root / "Copies", root / "Copies Originales"): names.update(p.stem for p in folder.glob("Copie*.pdf") if re.fullmatch(r"Copie[0-9]+", p.stem)) return sorted(names, key=lambda name: int(name[5:])) def _json_bytes(data) -> bytes: return (json.dumps(data, ensure_ascii=False, indent=2) + "\n").encode() def removal_plan(root: Path, copy: str) -> RemovalPlan: root = root.resolve() if not re.fullmatch(r"Copie[0-9]+", copy) or copy not in available_copies(root): raise ValueError("Choisissez une copie existante.") ident = copy[5:] def same_id(value): return isinstance(value, (str, int)) and str(value).removeprefix("Copie").isdecimal() and int(str(value).removeprefix("Copie")) == int(ident) def owned(name): match = re.match(r"^Copie([0-9]+)(?:$|[_. -])", name) return bool(match and same_id(match[1])) removed = set() files = [] # Never traverse symlinks or the backups themselves. for current, dirs, names in os.walk(root, followlinks=False): directory = Path(current) dirs[:] = [name for name in dirs if name not in {"BackUp", ".git"} and not (directory / name).is_symlink()] for name in list(dirs): returned = re.search(r" \(([0-9]+)\)$", name) if directory.name == "A Rendre" else None if owned(name) or (returned and same_id(returned[1])): removed.add(directory / name) dirs.remove(name) for name in names: path = directory / name if path.is_symlink(): continue if owned(name): removed.add(path) else: files.append(path) updated = {} dropped = object() def prune(value, *, rows=False, numeric_keys=False): if isinstance(value, dict): if any(same_id(value.get(key)) for key in ("id", "copy_id", "copy", "copie")): return dropped result = {} for key, item in value.items(): if owned(key) or (numeric_keys and same_id(key)): continue new = prune(item, rows=rows, numeric_keys=numeric_keys) if new is not dropped: result[key] = new return result if isinstance(value, list): if value and ((rows and same_id(value[0])) or (isinstance(value[0], str) and value[0] == copy)): return dropped return [new for item in value if (new := prune(item, rows=rows, numeric_keys=numeric_keys)) is not dropped] return value for path in files: relative = path.relative_to(root) # Historical logs and GUI history are audit records, not active inputs. if "logs" in relative.parts or path.name == ".copienator-gui.json": continue if path.name in {"manual_resolutions.txt", "annotation_modifications.txt"}: text = path.read_text() pattern = re.compile(r"\bCopie0*" + str(int(ident)) + r"\b") new_text = "".join(line for line in text.splitlines(keepends=True) if not pattern.search(line)) if new_text != text: updated[path] = new_text.encode() if path.suffix == ".json": data = read_json(path) # Fail before moving anything if an input is malformed. is_group = path.name.startswith("Group_") and "Par label" in relative.parts if is_group and isinstance(data, list): targets = [entry for entry in data if isinstance(entry, list) and entry and same_id(entry[0])] if targets: remaining = [entry for entry in data if entry not in targets] image_path = path.with_suffix(".jpg") if not remaining: removed.add(path) if image_path.exists(): removed.add(image_path) continue # Blank the removed answer without shifting other students' coordinates. from copienator.commands.grouping import LABEL_HEIGHT, SEPARATOR_HEIGHT with Image.open(image_path) as source: image = source.convert("RGB") draw = ImageDraw.Draw(image) for entry in targets: top = int(entry[1]) - LABEL_HEIGHT draw.rectangle((0, max(0, top - SEPARATOR_HEIGHT), image.width, int(entry[2]) - 1), fill="white") buffer = io.BytesIO() image.save(buffer, format="JPEG", quality=95) updated[image_path] = buffer.getvalue() numeric_keys = path.name in {"custom_remarks.json", "general_remarks.json"} new = prune(data, rows=is_group or path.name == "refaire.json", numeric_keys=numeric_keys) if new is dropped: removed.add(path) elif new != data: updated[path] = _json_bytes(new) # Cached contact sheets and batch payloads contain several students. Discard # them as a unit so stale recognition/results cannot reintroduce the copy. for directory in (root / "Cutleft/Names/Grids",): if directory.is_dir() and not directory.is_symlink(): removed.add(directory) for path in files: if (path.name.startswith(("batch_", "batched_")) or path.name in {"correction_progress.json", "correction_pending_responses.json", "histogramme.pdf", "merged_histogramme.pdf"}): removed.add(path) # Collapse children when a whole directory is being removed. removed = {p for p in removed if not any(parent in removed for parent in p.parents)} updated = {p: data for p, data in updated.items() if p not in removed and not any(parent in removed for parent in p.parents)} return RemovalPlan(root, copy, tuple(sorted(removed)), updated) def remove_copy(root: Path, copy: str) -> Path: """Back up every changed file first; roll back all edits on failure.""" root = root.resolve() if (root / "BackUp").is_symlink(): raise ValueError("BackUp doit être un dossier dans l’évaluation, pas un lien symbolique.") with EvaluationLock(root): plan = removal_plan(root, copy) backup = plan.root / "BackUp" / (datetime.now().strftime("%Y%m%d-%H%M%S") + "-" + copy + "-" + uuid.uuid4().hex[:8]) originals = backup / "files" originals.mkdir(parents=True) paths = [*plan.removed, *plan.updated] for path in paths: target = originals / path.relative_to(plan.root) target.parent.mkdir(parents=True, exist_ok=True) if path.is_dir(): shutil.copytree(path, target, symlinks=True) else: shutil.copy2(path, target) manifest = {"copy": copy, "removed": [str(p.relative_to(plan.root)) for p in plan.removed], "updated": [str(p.relative_to(plan.root)) for p in plan.updated], "status": "prepared"} atomic_write_bytes(backup / "manifest.json", _json_bytes(manifest)) changed = [] try: for path in plan.removed: changed.append(path) if path.is_dir(): shutil.rmtree(path) else: path.unlink() for path, data in plan.updated.items(): changed.append(path) atomic_write_bytes(path, data) manifest["status"] = "complete" atomic_write_bytes(backup / "manifest.json", _json_bytes(manifest)) except Exception: for path in reversed(changed): source = originals / path.relative_to(plan.root) if source.is_dir(): shutil.copytree(source, path, dirs_exist_ok=True, symlinks=True) else: shutil.copy2(source, path) raise return backup