diff --git a/CHANGELOG.md b/CHANGELOG.md index 72c9a4d..08b3560 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ Die Version steht an genau einer Stelle: `src/netmover/__init__.py`. ## [Unreleased] +## [0.9.0] – 2026-10-15 + +### Hinzugefügt +- **Regeln kopieren** (Knopf „Kopieren“): alle Einstellungen inklusive Archiv + übernommen, Name „… (Kopie)“. Die Kopie ist zunächst **deaktiviert**, damit sie nicht + mit dem Original um dieselben Dateien konkurriert; Protokoll und Buchführung + beginnen neu. + ## [0.8.0] – 2026-10-12 ### Hinzugefügt @@ -269,7 +277,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.8.0...HEAD +[Unreleased]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.9.0...HEAD +[0.9.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.8.0...v0.9.0 [0.8.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.7.0...v0.8.0 [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 diff --git a/DEVLOG.md b/DEVLOG.md index 5ea60a9..943ba3f 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -22,10 +22,22 @@ Zeiten in Ortszeit (Europe/Berlin). | 2026-10-11 | 08:43 | 08:55 | 0:12 | – | Anmeldung je Netz (0.6.0) | | 2026-10-12 | 00:14 | 00:34 | 0:20 | – | Archiv je Regel (0.7.0) | | 2026-10-12 | 00:46 | 00:55 | 0:09 | – | Anbindungen kopieren (0.8.0) | -| **Summe** | | | **3:58** | | | +| 2026-10-15 | 09:17 | 09:26 | 0:09 | – | Regeln kopieren (0.9.0) | +| **Summe** | | | **4:07** | | | --- +## 2026-10-15 · 09:17–09:26 · Regeln kopieren (0.9.0) + +**Erledigt** – Knopf „Kopieren“, Route `/rules/{id}/copy` (GET Formular, POST +speichern), `rule_copy_name`. 5 Tests (362 gesamt); Gegenprobe „Kopie aktiv“ schlägt +an (2 Tests). + +**Entscheidung** – Kopie startet deaktiviert: Mit identischer Quelle würden Original +und Kopie sonst sofort parallel dieselben Dateien bearbeiten (doppelte Lieferung bzw. +Kollisionen). Protokoll und Buchführung (`copied_files`, `archived_files`) werden +bewusst nicht mitkopiert – die Kopie ist eine neue Regel. + ## 2026-10-12 · 00:46–00:55 · Anbindungen kopieren (0.8.0) **Erledigt** – Knopf „Kopieren“, Route `/mounts/{id}/copy` (GET Formular, POST diff --git a/ROADMAP.md b/ROADMAP.md index 51bde57..d1df648 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -28,6 +28,7 @@ Legende: ✅ erledigt · 🔨 in Arbeit · 📋 geplant · 💡 Idee (noch nicht | Version | Thema | |---|---| +| 0.9.0 | Regeln kopieren (Kopie zunächst deaktiviert) | | 0.8.0 | Anbindungen kopieren inklusive Zugangsdaten | | 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 | diff --git a/compose.yml b/compose.yml index 38a3d17..0d4f8c5 100644 --- a/compose.yml +++ b/compose.yml @@ -4,7 +4,7 @@ services: netmover: build: . - image: netmover:0.8.0 + image: netmover:0.9.0 container_name: netmover restart: unless-stopped cap_add: diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index 8e8c114..a093200 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -226,6 +226,17 @@ Synology-Verwaltungsordner (`@eaDir`, `#recycle`). | **Modus setzen** | z. B. `0644`; leer = wie die Quelldatei | | **Quarantäneordner** | Dateien, die mehrmals hintereinander scheitern, werden dorthin verschoben (relativ zur Quell-Anbindung), statt es endlos zu versuchen. Leer = keine Quarantäne. | +### Regel kopieren {#regel-kopieren} + +Für eine ähnliche Regel – etwa für einen zweiten Quell-Unterordner – bei der Vorlage +auf **Kopieren** klicken. Alle Einstellungen (Filter, Ziel, Archiv, Sicherheit, +Rechte) sind übernommen, der Name lautet „… (Kopie)“. + +Die Kopie ist zunächst **deaktiviert**: Mit gleicher Quelle würde sie sonst sofort mit +dem Original um dieselben Dateien konkurrieren. Also Quelle bzw. Ziel anpassen, +speichern, **Probelauf**, dann **Aktivieren**. Protokoll und Buchführung beginnen bei +der Kopie neu. + ### Probelauf {#probelauf} **Probelauf** zeigt in einer Tabelle, welche Datei wohin ginge – **ohne etwas zu @@ -428,7 +439,9 @@ deaktiviert, Zugangsdaten müssen neu eingegeben werden. ### Jede Person in einen eigenen Ordner {#beispiel-unterordner} Scannt jede Person in einen eigenen Unterordner (`Scans/max`, `Scans/erika`): pro -Person eine Regel mit eigenem **Unterordner** in der Quelle und eigenem Ziel. +Person eine Regel mit eigenem **Unterordner** in der Quelle und eigenem Ziel. Am +schnellsten: die erste Regel einrichten, dann **Kopieren** und nur Unterordner und +Ziel ändern. ### Kopie als zusätzliche Sicherung {#beispiel-kopie} diff --git a/src/netmover/__init__.py b/src/netmover/__init__.py index b847c1a..8d52d17 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.8.0" +__version__ = "0.9.0" diff --git a/src/netmover/admin.py b/src/netmover/admin.py index f64a648..4edaab1 100644 --- a/src/netmover/admin.py +++ b/src/netmover/admin.py @@ -668,6 +668,16 @@ def parse_rule_form(s: Session, form: Mapping[str, str]) -> tuple[RuleInput, Err return d, errors +def rule_copy_name(s: Session, name: str) -> str: + """„Scans (Kopie)“, „Scans (Kopie 2)“, … – frei in der Datenbank.""" + base = f"{name} (Kopie)"[:100] + candidate, n = base, 1 + while s.scalar(select(Rule.id).where(Rule.name == candidate)) is not None: + n += 1 + candidate = f"{name} (Kopie {n})"[:100] + return candidate + + def save_rule(s: Session, d: RuleInput, rule_id: int | None = None) -> tuple[Rule | None, Errors]: dup = s.scalar(select(Rule.id).where(Rule.name == d.name)) if dup is not None and dup != rule_id: diff --git a/src/netmover/web/app.py b/src/netmover/web/app.py index 44e05d8..3c71e4a 100644 --- a/src/netmover/web/app.py +++ b/src/netmover/web/app.py @@ -44,6 +44,7 @@ from netmover.admin import ( mount_input_from_model, parse_mount_form, parse_rule_form, + rule_copy_name, rule_input_from_model, save_mount, save_rule, @@ -902,6 +903,7 @@ def _rule_form( errors: dict[str, str], rule_id: int | None, status_code: int = 200, + copy_of: str | None = None, ) -> Response: with nm(request).session_factory() as s: mounts = s.scalars(select(Mount).order_by(Mount.name)).all() @@ -915,6 +917,7 @@ def _rule_form( d=d, errors=errors, rule_id=rule_id, + copy_of=copy_of, src_mounts=mounts, src_options=[("", "– wählen –")] + [(m.id, f"{m.name} ({m.type.value})") for m in mounts], dst_options=[("", "– wählen –")] + [(m.id, f"{m.name} ({m.type.value})") for m in mounts], @@ -942,25 +945,47 @@ async def rule_edit(request: Request, rule_id: int) -> Response: return _rule_form(request, d, {}, rule_id) +@protected.get("/rules/{source_id}/copy", response_class=HTMLResponse) +async def rule_copy(request: Request, source_id: int) -> Response: + """Neue Regel, vorausgefüllt aus einer bestehenden – zunächst deaktiviert, damit sie + nicht sofort mit dem Original um dieselben Dateien konkurriert.""" + with nm(request).session_factory() as s: + src = s.get(Rule, source_id) + if src is None: + return redirect("/rules") + d = rule_input_from_model(src) + d.name = rule_copy_name(s, src.name) + d.enabled = False + source_name = src.name + return _rule_form(request, d, {}, None, copy_of=source_name) + + @protected.post("/rules/new", response_class=HTMLResponse) @protected.post("/rules/{rule_id}/edit", response_class=HTMLResponse) -async def rule_save(request: Request, rule_id: int | None = None) -> Response: +@protected.post("/rules/{source_id}/copy", response_class=HTMLResponse) +async def rule_save( + request: Request, rule_id: int | None = None, source_id: int | None = None +) -> Response: state = nm(request) form = _form_dict(await request.form()) with session_scope(state.session_factory) as s: + source = s.get(Rule, source_id) if source_id is not None else None + copy_of = source.name if source is not None else None d, errors = parse_rule_form(s, form) r = None if not errors: try: - r, errors = save_rule(s, d, rule_id) + r, errors = save_rule(s, d, rule_id) # Kopie: rule_id ist None → neue Regel except AdminError as exc: errors = {"_": str(exc)} if errors or r is None: s.rollback() - return _rule_form(request, d, errors, rule_id, status_code=400) - name = r.name + return _rule_form(request, d, errors, rule_id, status_code=400, copy_of=copy_of) + name, enabled = r.name, r.enabled state.scheduler.sync() flash(request, f"Regel „{name}“ gespeichert.") + if copy_of and not enabled: + flash(request, "Die Kopie ist deaktiviert. Probelauf machen, dann aktivieren.", "info") return redirect("/rules") diff --git a/src/netmover/web/templates/rule_form.html b/src/netmover/web/templates/rule_form.html index cb1d913..06130cd 100644 --- a/src/netmover/web/templates/rule_form.html +++ b/src/netmover/web/templates/rule_form.html @@ -1,8 +1,11 @@ {% extends "base.html" %} {% from "macros.html" import field, select, check %} -{% block title %}{{ 'Regel bearbeiten' if rule_id else 'Neue Regel' }}{% endblock %} +{% block title %}{{ 'Regel bearbeiten' if rule_id else ('Regel kopieren' if copy_of else 'Neue Regel') }}{% endblock %} {% block content %} -
Alle Einstellungen stammen aus „{{ copy_of }}“. Die Kopie ist zunächst + deaktiviert, damit sie nicht mit dem Original um dieselben Dateien konkurriert – Quelle bzw. Ziel + anpassen, speichern, Probelauf machen, dann aktivieren. Protokoll und Buchführung beginnen bei der Kopie neu.
{% endif %} {% if errors.get('_') %}{{ errors['_'] }}
{% endif %} {% if not src_mounts %}Zuerst eine Anbindung als Quelle anlegen.
{% endif %} Bearbeiten + Kopieren diff --git a/tests/test_copy_rule.py b/tests/test_copy_rule.py new file mode 100644 index 0000000..ce69d0f --- /dev/null +++ b/tests/test_copy_rule.py @@ -0,0 +1,168 @@ +"""0.9.0: Regeln kopieren – vorausgefüllt, zunächst deaktiviert.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from fastapi.testclient import TestClient +from sqlalchemy import select + +from netmover.db import session_scope +from netmover.models import ( + ArchiveKind, + ArchiveLayout, + CopiedFile, + Mount, + MountType, + Rule, + RuleAction, + Run, +) +from tests.webutil import post + + +def _setup(state: Any, tmp_path: Path) -> tuple[int, int]: + consume = tmp_path / "consume" + consume.mkdir() + with session_scope(state.session_factory) as s: + m = Mount( + name="NAS", + slug="nas", + type=MountType.CIFS, + server="nas", + share="scans", + mountpoint=str(tmp_path / "mnt" / "nas"), + ) + a = Mount( + name="Archiv", + slug="archiv", + type=MountType.CIFS, + server="nas", + share="archiv", + mountpoint=str(tmp_path / "mnt" / "archiv"), + ) + s.add_all([m, a]) + s.flush() + r = Rule( + name="Scans → Paperless", + src_mount_id=m.id, + src_subpath="max", + dst_path=str(consume), + pattern="*.pdf;*.jpg", + recursive=True, + interval_seconds=45, + stability_seconds=7, + 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, + ) + s.add(r) + s.flush() + s.add(Run(rule_id=r.id)) + s.add(CopiedFile(rule_id=r.id, src_relpath="a.pdf", size=1, mtime_ns=1, dst_path="x")) + return r.id, m.id + + +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) + return r + + +def _form_from_page(html: str) -> dict[str, str]: + """Wie ein Browser: alle Felder des Formulars mit ihren vorausgefüllten Werten.""" + import re + + form: dict[str, str] = {} + for m in re.finditer(r']*name="([^"]+)"[^>]*>', html): + tag, name = m.group(0), m.group(1) + if 'type="checkbox"' in tag: + if " checked" in tag: + form[name] = "on" + else: + v = re.search(r'value="([^"]*)"', tag) + form[name] = v.group(1) if v else "" + for m in re.finditer(r'', html, re.S): + sel = re.search(r'