feat(web): Archiv im Regelformular, Export/Import; Doku 0.7.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-12 00:22:39 +02:00
co-authored by Claude Opus 5.5
parent e59932ab75
commit 29b48e333d
10 changed files with 153 additions and 9 deletions
+15 -1
View File
@@ -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
+23 -1
View File
@@ -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
+1
View File
@@ -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 |
+1 -1
View File
@@ -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:
+31 -2
View File
@@ -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}
+1 -1
View File
@@ -1,3 +1,3 @@
"""NetMover – NFS/SMB-Mounts verwalten und Dateien zeitgesteuert verschieben."""
__version__ = "0.6.0"
__version__ = "0.7.0"
+52 -1
View File
@@ -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
+10 -1
View File
@@ -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")
+12 -1
View File
@@ -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]:
@@ -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) }}
</div></fieldset>
<fieldset><legend>Archiv <a class="legend-help" href="/help#archiv" title="Hilfe">?</a></legend><div class="fields">
{{ 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.') }}
<div data-show-for="archive_kind:mount">{{ select('archive_mount_id', 'Archiv-Anbindung', dst_options, d.archive_mount_id or '', errors=errors) }}</div>
<div data-show-for="archive_kind:mount local">{{ field('archive_path', 'Archivordner', d.archive_path, errors=errors, hint='Anbindung: relativer Unterordner (wird angelegt). Lokal: absoluter Pfad, muss existieren.') }}</div>
<div data-show-for="archive_kind:mount local">{{ 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.') }}</div>
</div></fieldset>
<fieldset><legend>Sicherheit und Grenzen <a class="legend-help" href="/help#regel-sicherheit" title="Hilfe">?</a></legend><div class="fields">
{{ 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"') }}