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
+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")