diff --git a/CHANGELOG.md b/CHANGELOG.md index ebab0bf..45eddb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ Die Version steht an genau einer Stelle: `src/netmover/__init__.py`. ## [Unreleased] +## [0.7.0] – 2026-10-12 + +### Hinzugefügt +- **Archiv je Regel:** zusätzliche Kopie in ein Archiv (Anbindung SMB/NFS/SFTP oder + lokaler Pfad), Ablage „wie in der Quelle“ oder „nach Datum“ (`Jahr/Monat/…`). + Reihenfolge: erst Archiv, dann Ziel, **erst dann wird die Quelle gelöscht**. + Scheitert das Ziel nach dem Archiv, verhindert eine Buchführung + (`archived_files`) eine zweite Archivkopie. Im Archiv wird nie überschrieben. + Nicht erreichbares Archiv → Regel wartet. Platzprüfung auch fürs Archiv, + Probelauf zeigt das Archivziel. Migration 0004. +- Export/Import enthalten die Archiv-Einstellungen; eine als Archiv benutzte + Anbindung lässt sich nicht löschen. + ## [0.6.0] – 2026-10-11 ### Geändert @@ -246,7 +259,8 @@ Erste Version. - Löschen von Zugangsdaten, die noch von einem Mount benutzt werden, trennt den Mount nicht mehr stillschweigend ab, sondern wird von der Datenbank abgelehnt. -[Unreleased]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.6.0...HEAD +[Unreleased]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.7.0...HEAD +[0.7.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.6.0...v0.7.0 [0.6.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.5.0...v0.6.0 [0.5.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.4.0...v0.5.0 [0.4.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.3.0...v0.4.0 diff --git a/DEVLOG.md b/DEVLOG.md index e061a1f..08c730f 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -20,10 +20,32 @@ Zeiten in Ortszeit (Europe/Berlin). | 2026-10-11 | 00:31 | 00:50 | 0:19 | – | Pausieren, Wartungsmodus, E-Mail (0.4.0) | | 2026-10-11 | 00:54 | 01:16 | 0:22 | – | SFTP-Quelle, Zugangsdaten, Anmeldung (0.5.0) | | 2026-10-11 | 08:43 | 08:55 | 0:12 | – | Anmeldung je Netz (0.6.0) | -| **Summe** | | | **3:29** | | | +| 2026-10-12 | 00:14 | 00:34 | 0:20 | – | Archiv je Regel (0.7.0) | +| **Summe** | | | **3:49** | | | --- +## 2026-10-12 · 00:14–00:34 · Archiv je Regel (0.7.0) + +**Erledigt** +- Archivfelder an der Regel, Tabelle `archived_files` (Migration 0004). +- Engine: Archiv zuerst, dann Ziel, dann Quelle löschen; Archiv immer „umbenennen“. +- Runner: Ziel- und Archivaufbau über eine gemeinsame Fabrik (`_target`). +- 14 Tests (350 gesamt); Mutationsprobe 4/4 erkannt (Buchführung, Archiv + übersprungen, Kollisionsregel, Platzprüfung). + +**Entscheidungen** +- Archiv *vor* Ziel: Paperless holt Dateien sofort ab – andersherum würde ein + gescheitertes Archiv beim Neuversuch zur doppelten Lieferung führen. +- Archiv überschreibt nie (unabhängig von der Kollisionsregel des Ziels). +- Ablage „nach Datum“ nach Verarbeitungsdatum (nicht Datei-mtime) – eindeutig + nachvollziehbar, wann eine Datei durchgelaufen ist. + +**Stolperstein** +- Downgrade von 0004 scheiterte an den CHECK-Regeln der neuen Auswahlfelder + (verweisen nach dem Entfernen der Spalten ins Leere) – nur dank der Migrations- + tests mit Daten aufgefallen; Downgrade entfernt sie jetzt zuerst. + ## 2026-10-11 · 08:43–08:55 · Anmeldung je Netz (0.6.0) **Anlass** – Wunsch: Anmeldung abschalten, und zwar konfigurierbar, aus welchen Netzen diff --git a/ROADMAP.md b/ROADMAP.md index 947a912..0cbdaf5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -28,6 +28,7 @@ Legende: ✅ erledigt · 🔨 in Arbeit · 📋 geplant · 💡 Idee (noch nicht | Version | Thema | |---|---| +| 0.7.0 | Archiv je Regel: Kopie ins Ziel und ins Archiv, Quelle erst danach löschen | | 0.6.0 | Anmeldung je Netz: ohne Passwort / mit Passwort / abgewiesen, Schutz vor Aussperren | | 0.5.0 | SFTP als Quelle; gespeicherte Zugangsdaten für SMB/SFTP-Passwörter (Seite „Zugangsdaten“); Anmeldung abschaltbar (vertrauenswürdige Netze / ganz) | | 0.4.0 | Automatisch pausieren/fortsetzen bei nicht erreichbaren Anbindungen; Wartungsmodus; E-Mail-Benachrichtigung; eine Meldung pro ausgefallener Anbindung | diff --git a/compose.yml b/compose.yml index 4b8ac39..87131a3 100644 --- a/compose.yml +++ b/compose.yml @@ -4,7 +4,7 @@ services: netmover: build: . - image: netmover:0.6.0 + image: netmover:0.7.0 container_name: netmover restart: unless-stopped cap_add: diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index 55fc328..22cb682 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -8,7 +8,7 @@ Diese Anleitung erklärt die Bedienung. Installation, Updates und technische Det stehen im [README](../README.md). **Inhalt:** [Begriffe](#begriffe) · [Erste Anmeldung](#erste-anmeldung) · -[Zugangsdaten](#zugangsdaten) · [Anbindungen](#anbindungen) · [Regeln](#regeln) · +[Zugangsdaten](#zugangsdaten) · [Anbindungen](#anbindungen) · [Regeln](#regeln) · [Archiv](#archiv) · [Probelauf](#probelauf) · [Status](#status) · [Wartungsmodus](#wartungsmodus) · [Protokoll](#protokoll) · [Einstellungen](#einstellungen) · [Anmeldung](#anmeldung) · [Uptime Kuma](#uptime-kuma) · [Beispiele](#beispiele) · [Häufige Fragen](#fragen) @@ -160,6 +160,34 @@ Gespeicherte Zugangsdaten lassen sich nur löschen, wenn sie keine Anbindung meh | **Aktion** | **verschieben** (Quelle wird nach erfolgreicher Übertragung gelöscht) oder **kopieren** (Quelle bleibt; jede Dateiversion wird nur einmal kopiert). | | **Wenn Datei existiert** | **umbenennen** (`scan.pdf` → `scan-1.pdf`, `scan-2.pdf` …), **überspringen** oder **überschreiben**. | +### Archiv {#archiv} + +Optional bekommt jede Datei zusätzlich eine **Kopie ins Archiv** – z. B. Paperless als +Ziel und ein Archivordner auf dem NAS. **Die Quelle wird erst gelöscht, wenn die Datei +im Ziel UND im Archiv angekommen ist.** + +| Feld | Bedeutung | +|---|---| +| **Archiv** | kein Archiv / eine Anbindung (SMB, NFS, SFTP) / ein lokaler Pfad | +| **Archivordner** | Anbindung: relativer Unterordner (wird angelegt). Lokal: absoluter Pfad, muss existieren. | +| **Ablage** | **wie in der Quelle** oder **nach Datum** – `2026/10/scan001.pdf` (Monat der Verarbeitung) | + +So läuft es pro Datei: + +1. **Zuerst ins Archiv** – mit denselben Sicherungen wie beim Ziel. +2. **Dann ins Ziel.** +3. **Dann erst** wird die Quelle gelöscht (bei „verschieben“). + +Das Archiv kommt zuerst, weil ein Ziel wie Paperless Dateien sofort abholt: Scheitert +das Archiv, wurde noch nichts geliefert, und die Datei bleibt in der Quelle. Scheitert +danach das Ziel, merkt sich NetMover, dass die Datei schon archiviert ist – der nächste +Versuch legt keine zweite Archivkopie an. + +Im Archiv wird **nie überschrieben**: Gleiche Namen werden immer umbenannt +(`scan-1.pdf`), egal was die Regel fürs Ziel vorsieht. Ist das Archiv nicht erreichbar, +wartet die Regel wie bei Quelle und Ziel. Im Kopiermodus bekommen Ziel und Archiv je +eine Kopie, die Quelle bleibt. + ### Sicherheit und Grenzen {#regel-sicherheit} | Feld | Bedeutung | Empfehlung | @@ -378,7 +406,8 @@ deaktiviert, Zugangsdaten müssen neu eingegeben werden. 3. Anbindung **Paperless** (SFTP, Pfad `/opt/paperless_data/consume`, Schlüssel wählen, Benutzer = der Paperless-Benutzer), testen. 4. Regel **Scans → Paperless**: Quelle NAS, Filter `*.pdf`, Ziel Paperless, - verschieben, umbenennen, 30 s. **Probelauf**, dann aktivieren. + verschieben, umbenennen, 30 s. Optional **Archiv**: Anbindung NAS, Ordner + `Archiv/Scans`, Ablage nach Datum. **Probelauf**, dann aktivieren. ### Jede Person in einen eigenen Ordner {#beispiel-unterordner} diff --git a/src/netmover/__init__.py b/src/netmover/__init__.py index 7ff7d1e..b627107 100644 --- a/src/netmover/__init__.py +++ b/src/netmover/__init__.py @@ -1,3 +1,3 @@ """NetMover – NFS/SMB-Mounts verwalten und Dateien zeitgesteuert verschieben.""" -__version__ = "0.6.0" +__version__ = "0.7.0" diff --git a/src/netmover/admin.py b/src/netmover/admin.py index d0500c8..581e086 100644 --- a/src/netmover/admin.py +++ b/src/netmover/admin.py @@ -18,6 +18,8 @@ from sqlalchemy.orm import Session from netmover.crypto import SecretBox, generate_ssh_keypair from netmover.models import ( + ArchiveKind, + ArchiveLayout, CollisionPolicy, Credential, CredentialKind, @@ -424,7 +426,11 @@ def delete_mount(s: Session, mounts: MountManager, mount_id: int) -> None: if m is None: return used = s.scalars( - select(Rule.name).where((Rule.src_mount_id == mount_id) | (Rule.dst_mount_id == mount_id)) + select(Rule.name).where( + (Rule.src_mount_id == mount_id) + | (Rule.dst_mount_id == mount_id) + | (Rule.archive_mount_id == mount_id) + ) ).all() if used: raise AdminError(f"Wird noch von Regeln benutzt: {', '.join(used)}") @@ -465,6 +471,10 @@ class RuleInput: dry_run: bool = False quarantine_path: str = "" quarantine_after: int = 3 + archive_kind: ArchiveKind = ArchiveKind.NONE + archive_mount_id: int | None = None + archive_path: str = "" + archive_layout: ArchiveLayout = ArchiveLayout.FLAT extra: dict[str, str] = field(default_factory=dict) @@ -492,6 +502,10 @@ def rule_input_from_model(r: Rule) -> RuleInput: dry_run=r.dry_run, quarantine_path=r.quarantine_path or "", quarantine_after=r.quarantine_after, + archive_kind=r.archive_kind, + archive_mount_id=r.archive_mount_id, + archive_path=r.archive_path, + archive_layout=r.archive_layout, ) @@ -585,6 +599,39 @@ def parse_rule_form(s: Session, form: Mapping[str, str]) -> tuple[RuleInput, Err d.quarantine_path = safe_relpath("quarantine_path", d.quarantine_path) except ValidationError as exc: errors["quarantine_path"] = str(exc) + + # Archiv + try: + d.archive_kind = ArchiveKind(form.get("archive_kind") or "none") + d.archive_layout = ArchiveLayout(form.get("archive_layout") or "flat") + except ValueError: + errors["archive_kind"] = "Ungültige Auswahl." + d.archive_path = (form.get("archive_path") or "").strip() + if d.archive_kind is ArchiveKind.MOUNT: + d.archive_mount_id = _int(form, "archive_mount_id", errors) + if d.archive_mount_id is None or s.get(Mount, d.archive_mount_id) is None: + errors["archive_mount_id"] = "Archiv-Anbindung wählen." + try: + d.archive_path = safe_relpath("archive_path", d.archive_path) + except ValidationError as exc: + errors["archive_path"] = str(exc) + if ( + "archive_path" not in errors + and d.archive_mount_id == d.dst_mount_id + and d.dst_kind == "mount" + and d.archive_path == d.dst_path + ): + errors["archive_path"] = "Archiv und Ziel sind derselbe Ordner." + elif d.archive_kind is ArchiveKind.LOCAL: + d.archive_mount_id = None + try: + d.archive_path = safe_abspath("archive_path", d.archive_path) + except ValidationError as exc: + errors["archive_path"] = str(exc) + if d.dst_kind == "local" and d.archive_path == d.dst_path: + errors["archive_path"] = "Archiv und Ziel sind derselbe Ordner." + else: + d.archive_mount_id, d.archive_path = None, "" return d, errors @@ -617,6 +664,10 @@ def save_rule(s: Session, d: RuleInput, rule_id: int | None = None) -> tuple[Rul "on_collision", "dry_run", "quarantine_after", + "archive_kind", + "archive_mount_id", + "archive_path", + "archive_layout", ): setattr(r, f, getattr(d, f)) r.set_owner = d.set_owner or None diff --git a/src/netmover/transfer.py b/src/netmover/transfer.py index bfdb2e4..1eab1e1 100644 --- a/src/netmover/transfer.py +++ b/src/netmover/transfer.py @@ -16,6 +16,8 @@ from sqlalchemy.orm import Session from netmover import __version__ from netmover.models import ( + ArchiveKind, + ArchiveLayout, CollisionPolicy, Mount, MountState, @@ -72,6 +74,9 @@ RULE_FIELDS = ( "dry_run", "quarantine_path", "quarantine_after", + "archive_kind", + "archive_path", + "archive_layout", ) SETTING_FIELDS = ( "retention_days", @@ -99,6 +104,7 @@ def export_config(s: Session, settings: AppSettings) -> dict[str, Any]: **{f: _plain(getattr(r, f)) for f in RULE_FIELDS}, "src_mount": names[r.src_mount_id], "dst_mount": names.get(r.dst_mount_id) if r.dst_mount_id else None, + "archive_mount": names.get(r.archive_mount_id) if r.archive_mount_id else None, } for r in s.scalars(select(Rule).order_by(Rule.name)) ], @@ -165,13 +171,16 @@ def import_config(s: Session, mounts: MountManager, data: dict[str, Any]) -> Imp try: src = ids[raw["src_mount"]] dst = ids[raw["dst_mount"]] if raw.get("dst_mount") else None - r = Rule(src_mount_id=src, dst_mount_id=dst) + arch = ids[raw["archive_mount"]] if raw.get("archive_mount") else None + r = Rule(src_mount_id=src, dst_mount_id=dst, archive_mount_id=arch) for f in RULE_FIELDS: if f in raw: setattr(r, f, raw[f]) r.pattern_type = PatternType(r.pattern_type) r.action = RuleAction(r.action) r.on_collision = CollisionPolicy(r.on_collision) + r.archive_kind = ArchiveKind(r.archive_kind or "none") + r.archive_layout = ArchiveLayout(r.archive_layout or "flat") r.src_subpath = safe_relpath("src_subpath", r.src_subpath or "") if int(r.interval_seconds) < 10: raise ValueError("Intervall < 10 s") diff --git a/src/netmover/web/app.py b/src/netmover/web/app.py index 2881995..77821f7 100644 --- a/src/netmover/web/app.py +++ b/src/netmover/web/app.py @@ -422,13 +422,24 @@ class RuleView: target: str +def _archive_target(r: Rule) -> str | None: + if r.archive_kind.value == "none": + return None + if r.archive_kind.value == "local" or r.archive_mount is None: + place = r.archive_path + else: + place = f"{r.archive_mount.name}:/{r.archive_path}" + return place + (" (nach Datum)" if r.archive_layout.value == "date" else "") + + def _rule_target(r: Rule) -> str: src = f"{r.src_mount.name}:/{r.src_subpath}" if r.src_subpath else f"{r.src_mount.name}:/" if r.dst_mount is None: dst = r.dst_path else: dst = f"{r.dst_mount.name}:/{r.dst_path}" if r.dst_path else f"{r.dst_mount.name}:/" - return f"{src} → {dst}" + archive = _archive_target(r) + return f"{src} → {dst}" + (f" + Archiv {archive}" if archive else "") async def _status_context(request: Request) -> dict[str, Any]: diff --git a/src/netmover/web/templates/rule_form.html b/src/netmover/web/templates/rule_form.html index 3a68187..cb1d913 100644 --- a/src/netmover/web/templates/rule_form.html +++ b/src/netmover/web/templates/rule_form.html @@ -30,6 +30,13 @@ {{ select('on_collision', 'Wenn Datei existiert', [('rename', 'umbenennen (scan-1.pdf)'), ('skip', 'überspringen'), ('overwrite', 'überschreiben')], d.on_collision.value, errors=errors) }} +
Archiv ?
+ {{ select('archive_kind', 'Archiv', [('none', 'kein Archiv'), ('mount', 'Anbindung'), ('local', 'Lokaler Pfad')], d.archive_kind.value, errors=errors, hint='Zusätzliche Kopie. Die Quelle wird erst gelöscht, wenn Ziel UND Archiv sie haben.') }} +
{{ select('archive_mount_id', 'Archiv-Anbindung', dst_options, d.archive_mount_id or '', errors=errors) }}
+
{{ field('archive_path', 'Archivordner', d.archive_path, errors=errors, hint='Anbindung: relativer Unterordner (wird angelegt). Lokal: absoluter Pfad, muss existieren.') }}
+
{{ select('archive_layout', 'Ablage', [('flat', 'wie in der Quelle'), ('date', 'nach Datum (Jahr/Monat)')], d.archive_layout.value, errors=errors, hint='Im Archiv wird nie überschrieben – gleiche Namen werden umbenannt.') }}
+
+
Sicherheit und Grenzen ?
{{ field('stability_seconds', 'Stabilitätsfenster (s)', d.stability_seconds, type='number', errors=errors, attrs='min="0"', hint='Größe und Zeit müssen so lange gleich bleiben') }} {{ field('min_age_seconds', 'Mindestalter (s)', d.min_age_seconds, type='number', errors=errors, attrs='min="0"') }}