196 lines
8.6 KiB
Python
196 lines
8.6 KiB
Python
"""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
|