feat: mehrere Ziele je Regel – bis zu 10, unabhängig erreichbar
CI / pruefen (3.12) (push) Waiting to run
CI / pruefen (3.13) (push) Waiting to run

- weitere Ziele (rule_targets, target_files) statt Archiv; Migration 0005
  übernimmt vorhandene Archive samt Buchführung
- erreichbare Ziele werden beliefert, fehlende nachgeliefert; Quelle erst
  gelöscht, wenn alle Ziele die Datei haben
- Regelformular mit 9 Plätzen, Probelauf je Ziel, Export/Import inkl. Altformat

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-24 00:56:24 +02:00
co-authored by Claude Opus 5.5
parent 47ccf90b56
commit fe64f031bd
21 changed files with 1629 additions and 690 deletions
-351
View File
@@ -1,351 +0,0 @@
"""0.7.0: Archiv – Kopie ans Ziel UND ins Archiv, Quelle erst danach löschen."""
from __future__ import annotations
import os
from collections.abc import Iterator
from datetime import datetime
from pathlib import Path
import pytest
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from netmover.db import session_scope
from netmover.models import (
ArchivedFile,
ArchiveKind,
CollisionPolicy,
ItemStatus,
Rule,
RuleAction,
RunStatus,
)
from netmover.mounting.fake import FakeMountBackend
from netmover.mounting.manager import MountManager
from netmover.mover.destinations import LocalDestination, TransferError
from netmover.mover.engine import Engine, RuleSnapshot, RunResult
from netmover.mover.ledger import Ledger
from netmover.mover.runner import RuleRunner
from netmover.timeouts import CancelToken
from tests.conftest import MountFactory
@pytest.fixture
def rule_id(make_mount: MountFactory, session_factory: sessionmaker[Session]) -> int:
mid = make_mount()
with session_scope(session_factory) as s:
r = Rule(name="r", src_mount_id=mid, dst_path="/x")
s.add(r)
s.flush()
return r.id
FIXED = datetime(2026, 10, 12, 9, 30).timestamp()
@pytest.fixture
def engine(session_factory: sessionmaker[Session]) -> Engine:
return Engine(Ledger(session_factory), sleep=lambda _: None, clock=lambda: FIXED)
@pytest.fixture
def dirs(tmp_path: Path) -> tuple[Path, Path, Path]:
src, dst, arch = tmp_path / "src", tmp_path / "dst", tmp_path / "archiv"
for d in (src, dst, arch):
d.mkdir()
return src, dst, arch
@pytest.fixture(autouse=True)
def cross_device(monkeypatch: pytest.MonkeyPatch) -> None:
"""tmp_path liegt auf einem Gerät – realistischer Weg über Kopie erzwingen."""
monkeypatch.setattr(LocalDestination, "move_same_device", lambda *a, **k: None)
def snap(rule_id: int, **kw: object) -> RuleSnapshot:
return RuleSnapshot(id=rule_id, name="r", stability_seconds=0, **kw) # type: ignore[arg-type]
def run(
engine: Engine, rule: RuleSnapshot, src: Path, dst: Path, arch: Path | None, **kw: object
) -> RunResult:
archive = LocalDestination(arch) if arch is not None else None
return engine.execute(rule, src, LocalDestination(dst), CancelToken(), archive=archive, **kw) # type: ignore[arg-type]
def write(p: Path, data: bytes = b"inhalt") -> Path:
p.parent.mkdir(parents=True, exist_ok=True)
p.write_bytes(data)
return p
def names(d: Path) -> list[str]:
return sorted(str(p.relative_to(d)) for p in d.rglob("*") if p.is_file())
def test_move_delivers_to_target_and_archive_then_deletes_source(
engine: Engine, rule_id: int, dirs: tuple[Path, Path, Path]
) -> None:
src, dst, arch = dirs
data = os.urandom(200_000)
write(src / "scan001.pdf", data)
result = run(engine, snap(rule_id), src, dst, arch)
assert result.status is RunStatus.OK
item = result.items[0]
assert item.status is ItemStatus.MOVED and item.message and "archiviert:" in item.message
assert (dst / "scan001.pdf").read_bytes() == data == (arch / "scan001.pdf").read_bytes()
assert not (src / "scan001.pdf").exists()
assert names(dst) == ["scan001.pdf"] and names(arch) == ["scan001.pdf"] # keine .part-Reste
def test_date_layout(engine: Engine, rule_id: int, dirs: tuple[Path, Path, Path]) -> None:
src, dst, arch = dirs
write(src / "max" / "a.pdf")
run(engine, snap(rule_id, recursive=True, archive_layout="date"), src, dst, arch)
assert names(arch) == ["2026/10/max/a.pdf"] and names(dst) == ["max/a.pdf"]
def test_archive_failure_delivers_nothing_and_keeps_source(
engine: Engine, rule_id: int, dirs: tuple[Path, Path, Path], monkeypatch: pytest.MonkeyPatch
) -> None:
src, dst, arch = dirs
write(src / "a.pdf")
real = LocalDestination._link_noreplace
def fail_in_archive(self: LocalDestination, tmp: str, final: str) -> None:
if self.root == arch:
raise TransferError("Archiv voll")
real(self, tmp, final)
monkeypatch.setattr(LocalDestination, "_link_noreplace", fail_in_archive)
result = run(engine, snap(rule_id), src, dst, arch)
item = result.items[0]
assert item.status is ItemStatus.FAILED and item.message == "Archiv: Archiv voll (Versuch 1)"
assert (src / "a.pdf").exists() and names(dst) == [] and names(arch) == []
def test_target_failure_after_archive_never_archives_twice(
engine: Engine,
rule_id: int,
dirs: tuple[Path, Path, Path],
monkeypatch: pytest.MonkeyPatch,
session_factory: sessionmaker[Session],
) -> None:
src, dst, arch = dirs
write(src / "a.pdf")
real = LocalDestination._link_noreplace
def fail_in_target(self: LocalDestination, tmp: str, final: str) -> None:
if self.root == dst:
raise TransferError("Ziel gesperrt")
real(self, tmp, final)
monkeypatch.setattr(LocalDestination, "_link_noreplace", fail_in_target)
first = run(engine, snap(rule_id), src, dst, arch)
assert first.items[0].status is ItemStatus.FAILED
assert (src / "a.pdf").exists() and names(arch) == ["a.pdf"] and names(dst) == []
with session_factory() as s:
assert len(s.scalars(select(ArchivedFile)).all()) == 1
monkeypatch.setattr(LocalDestination, "_link_noreplace", real) # Ziel wieder frei
second = run(engine, snap(rule_id), src, dst, arch)
assert second.items[0].status is ItemStatus.MOVED
assert names(arch) == ["a.pdf"] # keine zweite Archivkopie (a-1.pdf)
assert names(dst) == ["a.pdf"] and not (src / "a.pdf").exists()
def test_archive_never_overwrites(
engine: Engine, rule_id: int, dirs: tuple[Path, Path, Path]
) -> None:
src, dst, arch = dirs
write(arch / "scan.pdf", b"alt im Archiv")
write(src / "scan.pdf", b"neu")
run(engine, snap(rule_id, on_collision=CollisionPolicy.OVERWRITE), src, dst, arch)
assert (arch / "scan.pdf").read_bytes() == b"alt im Archiv"
assert (arch / "scan-1.pdf").read_bytes() == b"neu"
def test_no_space_in_archive_writes_nothing(
engine: Engine, rule_id: int, dirs: tuple[Path, Path, Path], monkeypatch: pytest.MonkeyPatch
) -> None:
src, dst, arch = dirs
write(src / "a.pdf", b"x" * 1000)
real = LocalDestination.free_bytes
monkeypatch.setattr(
LocalDestination, "free_bytes", lambda self: 10 if self.root == arch else real(self)
)
result = run(engine, snap(rule_id), src, dst, arch)
assert "zu wenig Platz im Archiv" in (result.items[0].message or "")
assert names(dst) == [] and names(arch) == [] and (src / "a.pdf").exists()
def test_source_changed_during_archive_copy(
engine: Engine, rule_id: int, dirs: tuple[Path, Path, Path], monkeypatch: pytest.MonkeyPatch
) -> None:
src, dst, arch = dirs
f = write(src / "a.pdf", b"original")
real = LocalDestination._apply_meta
def change(self: LocalDestination, rel: str, meta: object) -> list[str]:
if self.root == arch:
f.write_bytes(b"geaendert!!")
return real(self, rel, meta) # type: ignore[arg-type]
monkeypatch.setattr(LocalDestination, "_apply_meta", change)
result = run(engine, snap(rule_id), src, dst, arch)
assert result.items[0].status is ItemStatus.SKIPPED
assert names(dst) == [] and names(arch) == [] and f.exists()
def test_copy_mode_with_archive(
engine: Engine, rule_id: int, dirs: tuple[Path, Path, Path]
) -> None:
src, dst, arch = dirs
f = write(src / "a.pdf", b"v1")
rule = snap(rule_id, action=RuleAction.COPY)
for _ in range(3):
run(engine, rule, src, dst, arch)
assert f.exists() and names(dst) == ["a.pdf"] and names(arch) == ["a.pdf"]
f.write_bytes(b"version 2")
run(engine, rule, src, dst, arch)
assert names(dst) == ["a-1.pdf", "a.pdf"] and names(arch) == ["a-1.pdf", "a.pdf"]
def test_dry_run_shows_archive_and_writes_nothing(
engine: Engine, rule_id: int, dirs: tuple[Path, Path, Path]
) -> None:
src, dst, arch = dirs
write(src / "a.pdf")
result = run(engine, snap(rule_id, archive_layout="date"), src, dst, arch, dry_run=True)
assert result.items[0].status is ItemStatus.PLANNED
assert f"Archiv: {arch / '2026/10/a.pdf'}" in (result.items[0].message or "")
assert names(dst) == [] and names(arch) == [] and (src / "a.pdf").exists()
def test_without_archive_unchanged(
engine: Engine, rule_id: int, dirs: tuple[Path, Path, Path]
) -> None:
src, dst, arch = dirs
write(src / "a.pdf")
result = run(engine, snap(rule_id), src, dst, None)
assert result.items[0].message is None and names(arch) == []
# -- Regellauf ----------------------------------------------------------------------------------
@pytest.fixture
def setup(
manager: MountManager,
fake_backend: FakeMountBackend,
make_mount: MountFactory,
session_factory: sessionmaker[Session],
tmp_path: Path,
) -> Iterator[tuple[int, Path, Path, Path]]:
scans = fake_backend.export("nas", "scans")
arch_remote = fake_backend.export("nas", "archiv")
src_id = make_mount("scans", share="scans")
arch_id = make_mount("archiv", share="archiv")
manager.mount(src_id)
manager.mount(arch_id)
consume = tmp_path / "consume"
consume.mkdir()
with session_scope(session_factory) as s:
r = Rule(
name="Scans → Paperless + Archiv",
src_mount_id=src_id,
dst_path=str(consume),
stability_seconds=0,
archive_kind=ArchiveKind.MOUNT,
archive_mount_id=arch_id,
archive_path="scans",
)
s.add(r)
s.flush()
rid = r.id
yield rid, scans, consume, arch_remote
def test_runner_with_archive_mount(
setup: tuple[int, Path, Path, Path],
manager: MountManager,
session_factory: sessionmaker[Session],
) -> None:
rid, scans, consume, arch = setup
write(scans / "doc.pdf", b"%PDF")
result = RuleRunner(session_factory, manager, sleep=lambda _: None).run(rid)
assert result.status is RunStatus.OK, result.message
assert (
(consume / "doc.pdf").read_bytes() == b"%PDF" == (arch / "scans" / "doc.pdf").read_bytes()
)
assert not (scans / "doc.pdf").exists()
def test_unavailable_archive_makes_rule_wait(
setup: tuple[int, Path, Path, Path],
manager: MountManager,
fake_backend: FakeMountBackend,
session_factory: sessionmaker[Session],
) -> None:
rid, scans, consume, _arch = setup
write(scans / "doc.pdf")
fake_backend.vanish(manager.mountpoint_for("archiv"))
result = RuleRunner(session_factory, manager, sleep=lambda _: None).run(rid)
assert result.unavailable and result.status is RunStatus.ERROR
assert (scans / "doc.pdf").exists() and list(consume.iterdir()) == []
def test_mount_used_as_archive_cannot_be_deleted(
setup: tuple[int, Path, Path, Path],
session_factory: sessionmaker[Session],
manager: MountManager,
) -> None:
from netmover.admin import AdminError, delete_mount
from netmover.models import Mount
with session_scope(session_factory) as s:
arch_id = s.scalar(select(Mount.id).where(Mount.name == "archiv"))
assert arch_id is not None
with pytest.raises(AdminError, match="Wird noch von Regeln benutzt"):
delete_mount(s, manager, arch_id)
def test_rule_form_archive_validation(
session_factory: sessionmaker[Session], make_mount: MountFactory
) -> None:
from netmover.admin import parse_rule_form, save_rule
mid = make_mount()
base = {
"name": "x",
"src_mount_id": str(mid),
"dst_kind": "local",
"dst_path": "/srv/consume",
"interval_seconds": "30",
"stability_seconds": "5",
"op_timeout_seconds": "120",
}
with session_scope(session_factory) as s:
_, err = parse_rule_form(
s, {**base, "archive_kind": "local", "archive_path": "/srv/consume"}
)
assert "derselbe Ordner" in err["archive_path"]
_, err = parse_rule_form(s, {**base, "archive_kind": "mount", "archive_path": "a"})
assert "archive_mount_id" in err
_, err = parse_rule_form(s, {**base, "archive_kind": "local", "archive_path": "relativ"})
assert "absoluten Pfad" in err["archive_path"]
d, err = parse_rule_form(
s,
{
**base,
"archive_kind": "mount",
"archive_mount_id": str(mid),
"archive_path": "archiv/scans",
"archive_layout": "date",
},
)
assert err == {}
r, _ = save_rule(s, d)
assert r is not None and r.archive_kind is ArchiveKind.MOUNT
assert r.archive_path == "archiv/scans" and r.archive_layout.value == "date"
+25 -12
View File
@@ -10,14 +10,15 @@ from sqlalchemy import select
from netmover.db import session_scope
from netmover.models import (
ArchiveKind,
ArchiveLayout,
CopiedFile,
Mount,
MountType,
Rule,
RuleAction,
RuleTarget,
Run,
TargetKind,
TargetLayout,
)
from tests.webutil import post
@@ -56,10 +57,17 @@ def _setup(state: Any, tmp_path: Path) -> tuple[int, int]:
action=RuleAction.COPY,
set_mode="0640",
quarantine_path="_q",
archive_kind=ArchiveKind.MOUNT,
archive_mount_id=a.id,
archive_path="scans",
archive_layout=ArchiveLayout.DATE,
targets=[
RuleTarget(
position=0,
name="Archiv",
kind=TargetKind.MOUNT,
mount_id=a.id,
path="scans",
layout=TargetLayout.DATE,
),
RuleTarget(position=1, name="Buchhaltung", kind=TargetKind.LOCAL, path="/srv/buha"),
],
)
s.add(r)
s.flush()
@@ -71,7 +79,8 @@ def _setup(state: Any, tmp_path: Path) -> tuple[int, int]:
def _rule(state: Any, name: str) -> Rule:
with state.session_factory() as s:
r = s.scalars(select(Rule).where(Rule.name == name)).one()
s.expunge(r)
_ = list(r.targets) # vor dem Lösen aus der Session laden
s.expunge_all()
return r
@@ -105,7 +114,9 @@ def test_copy_form_is_prefilled_and_disabled(
assert form["name"] == "Scans → Paperless (Kopie)"
assert "enabled" not in form # Häkchen „Aktiv“ nicht gesetzt
assert form["pattern"] == "*.pdf;*.jpg" and form["src_subpath"] == "max"
assert form["archive_kind"] == "mount" and form["archive_layout"] == "date"
assert form["target_0_kind"] == "mount" and form["target_0_layout"] == "date"
assert form["target_0_name"] == "Archiv" and form["target_1_path"] == "/srv/buha"
assert form["target_2_kind"] == ""
def test_copy_saves_new_rule_with_all_settings(
@@ -131,12 +142,14 @@ def test_copy_saves_new_rule_with_all_settings(
"action",
"set_mode",
"quarantine_path",
"archive_kind",
"archive_mount_id",
"archive_path",
"archive_layout",
):
assert getattr(copy, f) == getattr(orig, f), f
def targets(r: Rule) -> list[tuple[object, ...]]:
return [(t.position, t.name, t.kind, t.mount_id, t.path, t.layout) for t in r.targets]
assert len(targets(copy)) == 2 and targets(copy) == targets(orig)
assert {t.id for t in copy.targets}.isdisjoint(t.id for t in orig.targets)
# Protokoll und Buchführung beginnen neu
with state.session_factory() as s:
assert s.scalars(select(Run).where(Run.rule_id == copy.id)).all() == []
+53
View File
@@ -219,3 +219,56 @@ def test_migration_files_are_named_after_their_revision() -> None:
versions = Path(__file__).parent.parent / "src/netmover/migrations/versions"
names = sorted(p.name.split("_", 1)[0] for p in versions.glob("[0-9]*.py"))
assert names == _revisions()
def test_0005_turns_archive_into_first_target_and_back(tmp_path: Path) -> None:
"""Archiv (0004) samt Buchführung wird zum weiteren Ziel „Archiv“ – und zurück."""
db = tmp_path / "v09.db"
url = f"sqlite:///{db}"
upgrade(url, "0001")
_seed(db)
upgrade(url, "0004")
c = sqlite3.connect(db)
c.executescript(
"""
UPDATE rules SET archive_kind = 'mount', archive_mount_id = 1,
archive_path = 'archiv', archive_layout = 'date';
INSERT INTO archived_files (rule_id, src_relpath, size, mtime_ns, archive_path,
archived_at)
VALUES (1, 'a.pdf', 3, 7, '/mnt/netmover/nas/archiv/2026/10/a.pdf',
'2026-10-12 09:00:00');
"""
)
c.commit()
c.close()
upgrade(url, "0005")
c = sqlite3.connect(db)
assert c.execute(
"SELECT rule_id, position, name, kind, mount_id, path, layout FROM rule_targets"
).fetchall() == [(1, 0, "Archiv", "mount", 1, "archiv", "date")]
assert c.execute("SELECT src_relpath, size, mtime_ns FROM target_files").fetchall() == [
("a.pdf", 3, 7)
]
cols = [r[1] for r in c.execute("PRAGMA table_info(rules)")]
assert not [col for col in cols if col.startswith("archive_")]
assert (
c.execute("SELECT name FROM sqlite_master WHERE name = 'archived_files'").fetchall() == []
)
# zweites Ziel – geht beim Downgrade verloren, das erste bleibt
c.execute(
"INSERT INTO rule_targets (rule_id, position, name, kind, mount_id, path, layout) "
"VALUES (1, 1, 'Buchhaltung', 'local', NULL, '/srv/buha', 'flat')"
)
c.commit()
c.close()
assert _state(db)["fk_violations"] == []
command.downgrade(alembic_config(url), "0004")
c = sqlite3.connect(db)
assert c.execute(
"SELECT archive_kind, archive_mount_id, archive_path, archive_layout FROM rules"
).fetchall() == [("mount", 1, "archiv", "date")]
assert c.execute("SELECT rule_id, src_relpath FROM archived_files").fetchall() == [(1, "a.pdf")]
c.close()
assert _state(db)["fk_violations"] == []
+683
View File
@@ -0,0 +1,683 @@
"""Weitere Ziele (0.11.0, vorher Archiv aus 0.7.0): Kopie an alle Ziele, Quelle erst
danach löschen; nicht erreichbare Ziele werden nachbeliefert."""
from __future__ import annotations
import os
from collections.abc import Iterator
from datetime import datetime
from pathlib import Path
from typing import Any
import pytest
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from netmover.db import session_scope
from netmover.models import (
CollisionPolicy,
ItemStatus,
Rule,
RuleAction,
RuleTarget,
RunStatus,
TargetFile,
TargetKind,
)
from netmover.mounting.fake import FakeMountBackend
from netmover.mounting.manager import MountManager
from netmover.mover.destinations import LocalDestination, TransferError
from netmover.mover.engine import Engine, RuleSnapshot, RunResult, Target
from netmover.mover.ledger import Ledger
from netmover.mover.runner import RuleRunner
from netmover.timeouts import CancelToken
from tests.conftest import MountFactory
@pytest.fixture
def rule_id(make_mount: MountFactory, session_factory: sessionmaker[Session]) -> int:
mid = make_mount()
with session_scope(session_factory) as s:
r = Rule(
name="r",
src_mount_id=mid,
dst_path="/x",
targets=[
RuleTarget(position=i, name=n, kind=TargetKind.LOCAL, path=f"/{n}")
for i, n in enumerate(("Archiv", "Buchhaltung", "Extra"))
],
)
s.add(r)
s.flush()
return r.id
@pytest.fixture
def tids(rule_id: int, session_factory: sessionmaker[Session]) -> dict[str, int]:
with session_factory() as s:
return {t.name: t.id for t in s.scalars(select(RuleTarget))}
FIXED = datetime(2026, 10, 12, 9, 30).timestamp()
@pytest.fixture
def engine(session_factory: sessionmaker[Session]) -> Engine:
return Engine(Ledger(session_factory), sleep=lambda _: None, clock=lambda: FIXED)
@pytest.fixture
def dirs(tmp_path: Path) -> tuple[Path, Path, Path]:
src, dst, arch = tmp_path / "src", tmp_path / "dst", tmp_path / "archiv"
for d in (src, dst, arch):
d.mkdir()
return src, dst, arch
@pytest.fixture(autouse=True)
def cross_device(monkeypatch: pytest.MonkeyPatch) -> None:
"""tmp_path liegt auf einem Gerät – realistischer Weg über Kopie erzwingen."""
monkeypatch.setattr(LocalDestination, "move_same_device", lambda *a, **k: None)
def snap(rule_id: int, **kw: object) -> RuleSnapshot:
return RuleSnapshot(id=rule_id, name="r", stability_seconds=0, **kw) # type: ignore[arg-type]
LAYOUT: dict[str, str] = {}
def run(
engine: Engine,
rule: RuleSnapshot,
src: Path,
dst: Path | None,
arch: Path | None,
*,
layout: str = "flat",
extra: list[Target] | None = None,
**kw: object,
) -> RunResult:
"""Hauptziel ``dst`` (None = nicht erreichbar) und Archiv ``arch`` (Ziel „Archiv“)."""
targets = list(extra or [])
if arch is not None:
targets.insert(0, Target(_TIDS["Archiv"], "Archiv", LocalDestination(arch), layout))
dest = LocalDestination(dst) if dst is not None else None
return engine.execute(rule, src, dest, CancelToken(), targets=targets, **kw) # type: ignore[arg-type]
_TIDS: dict[str, int] = {}
@pytest.fixture(autouse=True)
def _register_tids(request: pytest.FixtureRequest) -> None:
if "rule_id" in request.fixturenames:
_TIDS.clear()
_TIDS.update(request.getfixturevalue("tids"))
def write(p: Path, data: bytes = b"inhalt") -> Path:
p.parent.mkdir(parents=True, exist_ok=True)
p.write_bytes(data)
return p
def names(d: Path) -> list[str]:
return sorted(str(p.relative_to(d)) for p in d.rglob("*") if p.is_file())
def test_move_delivers_to_target_and_archive_then_deletes_source(
engine: Engine, rule_id: int, dirs: tuple[Path, Path, Path]
) -> None:
src, dst, arch = dirs
data = os.urandom(200_000)
write(src / "scan001.pdf", data)
result = run(engine, snap(rule_id), src, dst, arch)
assert result.status is RunStatus.OK
item = result.items[0]
assert item.status is ItemStatus.MOVED and item.message and "Archiv: " in item.message
assert (dst / "scan001.pdf").read_bytes() == data == (arch / "scan001.pdf").read_bytes()
assert not (src / "scan001.pdf").exists()
assert names(dst) == ["scan001.pdf"] and names(arch) == ["scan001.pdf"] # keine .part-Reste
def test_date_layout(engine: Engine, rule_id: int, dirs: tuple[Path, Path, Path]) -> None:
src, dst, arch = dirs
write(src / "max" / "a.pdf")
run(engine, snap(rule_id, recursive=True), src, dst, arch, layout="date")
assert names(arch) == ["2026/10/max/a.pdf"] and names(dst) == ["max/a.pdf"]
def test_archive_failure_delivers_nothing_and_keeps_source(
engine: Engine, rule_id: int, dirs: tuple[Path, Path, Path], monkeypatch: pytest.MonkeyPatch
) -> None:
src, dst, arch = dirs
write(src / "a.pdf")
real = LocalDestination._link_noreplace
def fail_in_archive(self: LocalDestination, tmp: str, final: str) -> None:
if self.root == arch:
raise TransferError("Archiv voll")
real(self, tmp, final)
monkeypatch.setattr(LocalDestination, "_link_noreplace", fail_in_archive)
result = run(engine, snap(rule_id), src, dst, arch)
item = result.items[0]
assert item.status is ItemStatus.FAILED and item.message == "Archiv: Archiv voll (Versuch 1)"
assert (src / "a.pdf").exists() and names(dst) == [] and names(arch) == []
def test_target_failure_after_archive_never_archives_twice(
engine: Engine,
rule_id: int,
dirs: tuple[Path, Path, Path],
monkeypatch: pytest.MonkeyPatch,
session_factory: sessionmaker[Session],
) -> None:
src, dst, arch = dirs
write(src / "a.pdf")
real = LocalDestination._link_noreplace
def fail_in_target(self: LocalDestination, tmp: str, final: str) -> None:
if self.root == dst:
raise TransferError("Ziel gesperrt")
real(self, tmp, final)
monkeypatch.setattr(LocalDestination, "_link_noreplace", fail_in_target)
first = run(engine, snap(rule_id), src, dst, arch)
assert first.items[0].status is ItemStatus.FAILED
assert (src / "a.pdf").exists() and names(arch) == ["a.pdf"] and names(dst) == []
with session_factory() as s:
assert len(s.scalars(select(TargetFile)).all()) == 1
monkeypatch.setattr(LocalDestination, "_link_noreplace", real) # Ziel wieder frei
second = run(engine, snap(rule_id), src, dst, arch)
assert second.items[0].status is ItemStatus.MOVED
assert names(arch) == ["a.pdf"] # keine zweite Archivkopie (a-1.pdf)
assert names(dst) == ["a.pdf"] and not (src / "a.pdf").exists()
def test_archive_never_overwrites(
engine: Engine, rule_id: int, dirs: tuple[Path, Path, Path]
) -> None:
src, dst, arch = dirs
write(arch / "scan.pdf", b"alt im Archiv")
write(src / "scan.pdf", b"neu")
run(engine, snap(rule_id, on_collision=CollisionPolicy.OVERWRITE), src, dst, arch)
assert (arch / "scan.pdf").read_bytes() == b"alt im Archiv"
assert (arch / "scan-1.pdf").read_bytes() == b"neu"
def test_no_space_in_archive_writes_nothing(
engine: Engine, rule_id: int, dirs: tuple[Path, Path, Path], monkeypatch: pytest.MonkeyPatch
) -> None:
src, dst, arch = dirs
write(src / "a.pdf", b"x" * 1000)
real = LocalDestination.free_bytes
monkeypatch.setattr(
LocalDestination, "free_bytes", lambda self: 10 if self.root == arch else real(self)
)
result = run(engine, snap(rule_id), src, dst, arch)
assert "Archiv: zu wenig Platz" in (result.items[0].message or "")
assert names(dst) == [] and names(arch) == [] and (src / "a.pdf").exists()
def test_source_changed_during_archive_copy(
engine: Engine, rule_id: int, dirs: tuple[Path, Path, Path], monkeypatch: pytest.MonkeyPatch
) -> None:
src, dst, arch = dirs
f = write(src / "a.pdf", b"original")
real = LocalDestination._apply_meta
def change(self: LocalDestination, rel: str, meta: object) -> list[str]:
if self.root == arch:
f.write_bytes(b"geaendert!!")
return real(self, rel, meta) # type: ignore[arg-type]
monkeypatch.setattr(LocalDestination, "_apply_meta", change)
result = run(engine, snap(rule_id), src, dst, arch)
assert result.items[0].status is ItemStatus.SKIPPED
assert names(dst) == [] and names(arch) == [] and f.exists()
def test_copy_mode_with_archive(
engine: Engine, rule_id: int, dirs: tuple[Path, Path, Path]
) -> None:
src, dst, arch = dirs
f = write(src / "a.pdf", b"v1")
rule = snap(rule_id, action=RuleAction.COPY)
for _ in range(3):
run(engine, rule, src, dst, arch)
assert f.exists() and names(dst) == ["a.pdf"] and names(arch) == ["a.pdf"]
f.write_bytes(b"version 2")
run(engine, rule, src, dst, arch)
assert names(dst) == ["a-1.pdf", "a.pdf"] and names(arch) == ["a-1.pdf", "a.pdf"]
def test_dry_run_shows_archive_and_writes_nothing(
engine: Engine, rule_id: int, dirs: tuple[Path, Path, Path]
) -> None:
src, dst, arch = dirs
write(src / "a.pdf")
result = run(engine, snap(rule_id), src, dst, arch, layout="date", dry_run=True)
assert result.items[0].status is ItemStatus.PLANNED
assert f"Archiv: {arch / '2026/10/a.pdf'}" in (result.items[0].message or "")
assert names(dst) == [] and names(arch) == [] and (src / "a.pdf").exists()
def test_without_archive_unchanged(
engine: Engine, rule_id: int, dirs: tuple[Path, Path, Path]
) -> None:
src, dst, arch = dirs
write(src / "a.pdf")
result = run(engine, snap(rule_id), src, dst, None)
assert result.items[0].message is None and names(arch) == []
# -- mehrere Ziele ------------------------------------------------------------------------------
def _three(tmp_path: Path, rule_id: int, tids: dict[str, int], down: str = "") -> list[Target]:
"""Archiv, Buchhaltung, Extra als lokale Ordner; ``down`` = nicht erreichbar."""
out = []
for name in ("Archiv", "Buchhaltung", "Extra"):
d = tmp_path / name.lower()
d.mkdir(exist_ok=True)
dest = None if name == down else LocalDestination(d)
out.append(Target(tids[name], name, dest, reason=f"„{name}“ nicht erreichbar"))
return out
def test_four_targets_all_get_the_file_then_source_is_deleted(
engine: Engine, rule_id: int, tids: dict[str, int], tmp_path: Path
) -> None:
src, dst = tmp_path / "src", tmp_path / "dst"
src.mkdir()
dst.mkdir()
data = os.urandom(50_000)
write(src / "rechnung.pdf", data)
targets = _three(tmp_path, rule_id, tids)
result = run(engine, snap(rule_id), src, dst, None, extra=targets)
assert result.status is RunStatus.OK and result.waiting_on == []
item = result.items[0]
assert item.status is ItemStatus.MOVED
for name in ("Archiv", "Buchhaltung", "Extra"):
assert f"{name}: " in (item.message or "")
assert (tmp_path / name.lower() / "rechnung.pdf").read_bytes() == data
assert (dst / "rechnung.pdf").read_bytes() == data
assert not (src / "rechnung.pdf").exists()
def test_unreachable_target_others_are_served_and_caught_up_later(
engine: Engine,
rule_id: int,
tids: dict[str, int],
tmp_path: Path,
session_factory: sessionmaker[Session],
) -> None:
src, dst = tmp_path / "src", tmp_path / "dst"
src.mkdir()
dst.mkdir()
write(src / "r1.pdf", b"%PDF-1")
down = _three(tmp_path, rule_id, tids, down="Buchhaltung")
first = run(engine, snap(rule_id), src, dst, None, extra=down)
assert first.waiting_on == ["„Buchhaltung“ nicht erreichbar"]
item = first.items[0]
assert item.status is ItemStatus.SKIPPED and "wartet auf: Buchhaltung" in (item.message or "")
# Hauptziel und erreichbare Ziele haben die Datei, die Quelle bleibt
assert (dst / "r1.pdf").exists() and (tmp_path / "archiv" / "r1.pdf").exists()
assert (tmp_path / "extra" / "r1.pdf").exists() and (src / "r1.pdf").exists()
assert not (tmp_path / "buchhaltung" / "r1.pdf").exists()
# Weitere Läufe während des Ausfalls: nichts doppelt, kein Protokolleintrag
again = run(engine, snap(rule_id), src, dst, None, extra=down)
assert again.items == [] and names(dst) == ["r1.pdf"]
assert names(tmp_path / "archiv") == ["r1.pdf"]
# Buchhaltung wieder da → nachbeliefert, erst jetzt Quelle gelöscht
back = run(engine, snap(rule_id), src, dst, None, extra=_three(tmp_path, rule_id, tids))
item = back.items[0]
assert item.status is ItemStatus.MOVED, item.message
assert "Hauptziel hatte die Datei bereits" in (item.message or "")
assert names(tmp_path / "buchhaltung") == ["r1.pdf"]
assert names(dst) == ["r1.pdf"] and names(tmp_path / "archiv") == ["r1.pdf"]
assert not (src / "r1.pdf").exists()
def test_unreachable_main_target_other_targets_still_served(
engine: Engine, rule_id: int, tids: dict[str, int], tmp_path: Path
) -> None:
src, dst = tmp_path / "src", tmp_path / "dst"
src.mkdir()
dst.mkdir()
write(src / "r.pdf")
targets = _three(tmp_path, rule_id, tids)
first = run(engine, snap(rule_id), src, None, None, extra=targets, dest_reason="DMS weg")
assert first.waiting_on == ["DMS weg"]
assert "wartet auf: Hauptziel" in (first.items[0].message or "")
assert names(tmp_path / "archiv") == ["r.pdf"] and (src / "r.pdf").exists()
second = run(engine, snap(rule_id), src, dst, None, extra=targets)
assert second.items[0].status is ItemStatus.MOVED
assert names(dst) == ["r.pdf"] and names(tmp_path / "extra") == ["r.pdf"]
assert not (src / "r.pdf").exists()
def test_copy_mode_new_target_gets_backfilled(
engine: Engine, rule_id: int, tids: dict[str, int], tmp_path: Path
) -> None:
src, dst = tmp_path / "src", tmp_path / "dst"
src.mkdir()
dst.mkdir()
write(src / "a.pdf")
rule = snap(rule_id, action=RuleAction.COPY)
targets = _three(tmp_path, rule_id, tids)
run(engine, rule, src, dst, None, extra=targets[:1])
run(engine, rule, src, dst, None, extra=targets[:1])
assert names(tmp_path / "archiv") == ["a.pdf"] and names(dst) == ["a.pdf"]
# Ziel hinzugefügt: nur das neue Ziel bekommt die Datei
result = run(engine, rule, src, dst, None, extra=targets[:2])
assert result.items[0].status is ItemStatus.COPIED
assert names(tmp_path / "buchhaltung") == ["a.pdf"]
assert names(dst) == ["a.pdf"] and names(tmp_path / "archiv") == ["a.pdf"]
def test_dry_run_lists_every_target(
engine: Engine, rule_id: int, tids: dict[str, int], tmp_path: Path
) -> None:
src, dst = tmp_path / "src", tmp_path / "dst"
src.mkdir()
dst.mkdir()
write(src / "a.pdf")
targets = _three(tmp_path, rule_id, tids, down="Extra")
result = run(engine, snap(rule_id), src, dst, None, extra=targets, dry_run=True)
msg = result.items[0].message or ""
assert (
"würde verschieben" in msg and "Buchhaltung: " in msg and "Extra: nicht erreichbar" in msg
)
assert (src / "a.pdf").exists() and names(dst) == []
# -- Regellauf ----------------------------------------------------------------------------------
@pytest.fixture
def setup(
manager: MountManager,
fake_backend: FakeMountBackend,
make_mount: MountFactory,
session_factory: sessionmaker[Session],
tmp_path: Path,
) -> Iterator[tuple[int, Path, Path, Path, Path]]:
scans = fake_backend.export("nas", "scans")
arch_remote = fake_backend.export("nas", "archiv")
buha_remote = fake_backend.export("nas", "buha")
src_id = make_mount("scans", share="scans")
arch_id = make_mount("archiv", share="archiv")
buha_id = make_mount("buha", share="buha")
for mid in (src_id, arch_id, buha_id):
manager.mount(mid)
consume = tmp_path / "consume"
consume.mkdir()
with session_scope(session_factory) as s:
r = Rule(
name="Rechnungen → DMS + Buchhaltung + Archiv",
src_mount_id=src_id,
dst_path=str(consume),
stability_seconds=0,
targets=[
RuleTarget(position=0, name="Buchhaltung", kind=TargetKind.MOUNT, mount_id=buha_id),
RuleTarget(
position=1,
name="Archiv",
kind=TargetKind.MOUNT,
mount_id=arch_id,
path="rechnungen",
),
],
)
s.add(r)
s.flush()
rid = r.id
yield rid, scans, consume, arch_remote, buha_remote
def test_runner_with_three_targets(
setup: tuple[int, Path, Path, Path, Path],
manager: MountManager,
session_factory: sessionmaker[Session],
) -> None:
rid, scans, consume, arch, buha = setup
write(scans / "re.pdf", b"%PDF")
result = RuleRunner(session_factory, manager, sleep=lambda _: None).run(rid)
assert result.status is RunStatus.OK, result.message
for path in (consume / "re.pdf", arch / "rechnungen" / "re.pdf", buha / "re.pdf"):
assert path.read_bytes() == b"%PDF"
assert not (scans / "re.pdf").exists()
def test_runner_unavailable_target_waits_without_blocking_others(
setup: tuple[int, Path, Path, Path, Path],
manager: MountManager,
fake_backend: FakeMountBackend,
session_factory: sessionmaker[Session],
) -> None:
from netmover.runs import RunService
rid, scans, consume, arch, buha = setup
write(scans / "re.pdf")
fake_backend.vanish(manager.mountpoint_for("buha"))
fake_backend.fail_next_mount(times=50)
runner = RuleRunner(session_factory, manager, sleep=lambda _: None)
service = RunService(session_factory, runner)
service.execute(rid)
assert (consume / "re.pdf").exists() and (arch / "rechnungen" / "re.pdf").exists()
assert (scans / "re.pdf").exists() and not (buha / "re.pdf").exists()
with session_factory() as s:
rule = s.get(Rule, rid)
assert rule is not None and rule.waiting_since is not None
assert "buha" in (rule.waiting_reason or "")
runs_before = len(rule.runs)
# weiterer Lauf während des Ausfalls: kein neuer Protokolleintrag
assert service.execute(rid) is None
with session_factory() as s:
rule = s.get(Rule, rid)
assert rule is not None and len(rule.runs) == runs_before
fake_backend.mount_failures.clear()
with session_factory() as s:
buha_id = s.scalar(select(RuleTarget.mount_id).where(RuleTarget.name == "Buchhaltung"))
assert buha_id is not None
manager.mount(buha_id) # Anbindung wieder da
service.execute(rid)
assert (buha / "re.pdf").exists() and not (scans / "re.pdf").exists()
assert len(list(consume.iterdir())) == 1 # nicht doppelt geliefert
with session_factory() as s:
rule = s.get(Rule, rid)
assert rule is not None and rule.waiting_since is None
def test_all_targets_unavailable_makes_rule_wait(
setup: tuple[int, Path, Path, Path, Path],
manager: MountManager,
fake_backend: FakeMountBackend,
session_factory: sessionmaker[Session],
) -> None:
rid, scans, _consume, _arch, _buha = setup
with session_scope(session_factory) as s:
r = s.get(Rule, rid)
assert r is not None
r.dst_mount_id = r.targets[0].mount_id # Hauptziel ebenfalls auf „buha“
r.dst_path = "dms"
r.targets = r.targets[:1]
write(scans / "doc.pdf")
fake_backend.vanish(manager.mountpoint_for("buha"))
fake_backend.fail_next_mount(times=50)
result = RuleRunner(session_factory, manager, sleep=lambda _: None).run(rid)
assert result.unavailable and result.status is RunStatus.ERROR
assert (scans / "doc.pdf").exists()
def test_mount_used_as_target_cannot_be_deleted(
setup: tuple[int, Path, Path, Path, Path],
session_factory: sessionmaker[Session],
manager: MountManager,
) -> None:
from netmover.admin import AdminError, delete_mount
from netmover.models import Mount
with session_scope(session_factory) as s:
arch_id = s.scalar(select(Mount.id).where(Mount.name == "archiv"))
assert arch_id is not None
with pytest.raises(AdminError, match="Wird noch von Regeln benutzt"):
delete_mount(s, manager, arch_id)
# -- Formular ---------------------------------------------------------------------------------
def _base(mid: int) -> dict[str, str]:
return {
"name": "x",
"src_mount_id": str(mid),
"dst_kind": "local",
"dst_path": "/srv/consume",
"interval_seconds": "30",
"stability_seconds": "5",
"op_timeout_seconds": "120",
}
def test_rule_form_target_validation(
session_factory: sessionmaker[Session], make_mount: MountFactory
) -> None:
from netmover.admin import parse_rule_form
mid = make_mount()
base = _base(mid)
with session_scope(session_factory) as s:
_, err = parse_rule_form(
s, {**base, "target_0_kind": "local", "target_0_path": "/srv/consume"}
)
assert "Hauptziel" in err["target_0_path"]
_, err = parse_rule_form(s, {**base, "target_0_kind": "mount", "target_0_path": "a"})
assert "target_0_mount_id" in err
_, err = parse_rule_form(s, {**base, "target_0_kind": "local", "target_0_path": "rel"})
assert "absoluten Pfad" in err["target_0_path"]
two = {
"target_0_kind": "local",
"target_0_path": "/srv/a",
"target_1_kind": "local",
"target_1_path": "/srv/a",
}
_, err = parse_rule_form(s, {**base, **two})
assert "Derselbe Ordner" in err["target_1_path"]
_, err = parse_rule_form(
s,
{
**base,
**two,
"target_1_path": "/srv/b",
"target_0_name": "X",
"target_1_name": "X",
},
)
assert "schon vor" in err["target_1_name"]
_, err = parse_rule_form(
s, {**base, "target_0_kind": "mount", "target_0_mount_id": str(mid)}
)
assert "derselbe Ordner" in err["target_0_path"] # = Quelle
def test_rule_form_saves_compacts_and_keeps_ledger(
session_factory: sessionmaker[Session], make_mount: MountFactory
) -> None:
from netmover.admin import parse_rule_form, save_rule
mid = make_mount()
arch = make_mount("archiv", share="archiv")
form = {
**_base(mid),
# Platz 0 leer gelassen, Plätze 1 und 3 belegt → kompakt 0, 1
"target_1_kind": "mount",
"target_1_mount_id": str(arch),
"target_1_path": "rechnungen",
"target_1_layout": "date",
"target_3_kind": "local",
"target_3_name": "Buchhaltung",
"target_3_path": "/srv/buha",
}
with session_scope(session_factory) as s:
d, err = parse_rule_form(s, form)
assert err == {}
r, _ = save_rule(s, d)
assert r is not None
rid = r.id
got = [(t.position, t.name, t.kind.value, t.path, t.layout.value) for t in r.targets]
assert got == [
(0, "archiv", "mount", "rechnungen", "date"), # Name = Anbindung
(1, "Buchhaltung", "local", "/srv/buha", "flat"),
]
first, second = r.targets[0].id, r.targets[1].id
for tid in (first, second):
s.add(TargetFile(target_id=tid, src_relpath="a", size=1, mtime_ns=1, dst_path="x"))
# erneut speichern: Archiv unverändert, Buchhaltung mit neuem Ordner
with session_scope(session_factory) as s:
d, err = parse_rule_form(s, {**form, "target_3_path": "/srv/buha2"})
assert err == {}
save_rule(s, d, rid)
with session_factory() as s:
r = s.get(Rule, rid)
assert r is not None and [t.id for t in r.targets] == [first, second]
kept = {f.target_id for f in s.scalars(select(TargetFile))}
assert kept == {first} # Buchführung nur bei geändertem Ordner verworfen
# Ziel entfernen
with session_scope(session_factory) as s:
d, _ = parse_rule_form(s, {**form, "target_3_kind": ""})
save_rule(s, d, rid)
with session_factory() as s:
r = s.get(Rule, rid)
assert r is not None and [t.name for t in r.targets] == ["archiv"]
def test_rule_form_page_has_nine_target_slots(client: Any, state: Any) -> None:
html = client.get("/rules/new").text
assert "Weitere Ziele" in html and 'name="target_8_kind"' in html
assert 'name="target_9_kind"' not in html
assert 'data-show-for="target_0_kind:mount local|target_1_kind:mount local"' in html
# -- Export / Import --------------------------------------------------------------------------
def test_export_import_targets_and_legacy_archive(
setup: tuple[int, Path, Path, Path, Path],
session_factory: sessionmaker[Session],
manager: MountManager,
) -> None:
from netmover.settings_store import AppSettings
from netmover.transfer import export_config, import_config
with session_factory() as s:
data = export_config(s, AppSettings())
(rule,) = data["rules"]
assert [(t["name"], t["mount"], t["path"]) for t in rule["targets"]] == [
("Buchhaltung", "buha", ""),
("Archiv", "archiv", "rechnungen"),
]
new = {**rule, "name": "Kopie"}
legacy = {k: v for k, v in rule.items() if k != "targets"} | {
"name": "Alt",
"archive_kind": "mount",
"archive_mount": "archiv",
"archive_path": "alt",
"archive_layout": "date",
}
with session_scope(session_factory) as s:
report = import_config(s, manager, {**data, "mounts": [], "rules": [new, legacy]})
assert report.errors == [] and len(report.created) == 2
with session_factory() as s:
by_name = {r.name: r for r in s.scalars(select(Rule))}
assert [t.name for t in by_name["Kopie"].targets] == ["Buchhaltung", "Archiv"]
(t,) = by_name["Alt"].targets
assert (t.name, t.path, t.layout.value) == ("Archiv", "alt", "date")