1 Commits
Author SHA1 Message Date
Florian WildeandClaude Opus 5.5 b7577c4552 feat: Regeln kopieren – vorausgefüllt, zunächst deaktiviert
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:19:59 +02:00
11 changed files with 253 additions and 11 deletions
+10 -1
View File
@@ -8,6 +8,14 @@ Die Version steht an genau einer Stelle: `src/netmover/__init__.py`.
## [Unreleased] ## [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 ## [0.8.0] – 2026-10-12
### Hinzugefügt ### Hinzugefügt
@@ -269,7 +277,8 @@ Erste Version.
- Löschen von Zugangsdaten, die noch von einem Mount benutzt werden, trennt den - Löschen von Zugangsdaten, die noch von einem Mount benutzt werden, trennt den
Mount nicht mehr stillschweigend ab, sondern wird von der Datenbank abgelehnt. 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.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.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.6.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.5.0...v0.6.0
+13 -1
View File
@@ -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-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: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) | | 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) ## 2026-10-12 · 00:46–00:55 · Anbindungen kopieren (0.8.0)
**Erledigt** – Knopf „Kopieren“, Route `/mounts/{id}/copy` (GET Formular, POST **Erledigt** – Knopf „Kopieren“, Route `/mounts/{id}/copy` (GET Formular, POST
+1
View File
@@ -28,6 +28,7 @@ Legende: ✅ erledigt · 🔨 in Arbeit · 📋 geplant · 💡 Idee (noch nicht
| Version | Thema | | Version | Thema |
|---|---| |---|---|
| 0.9.0 | Regeln kopieren (Kopie zunächst deaktiviert) |
| 0.8.0 | Anbindungen kopieren inklusive Zugangsdaten | | 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.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.6.0 | Anmeldung je Netz: ohne Passwort / mit Passwort / abgewiesen, Schutz vor Aussperren |
+1 -1
View File
@@ -4,7 +4,7 @@
services: services:
netmover: netmover:
build: . build: .
image: netmover:0.8.0 image: netmover:0.9.0
container_name: netmover container_name: netmover
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
+14 -1
View File
@@ -226,6 +226,17 @@ Synology-Verwaltungsordner (`@eaDir`, `#recycle`).
| **Modus setzen** | z. B. `0644`; leer = wie die Quelldatei | | **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. | | **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 {#probelauf}
**Probelauf** zeigt in einer Tabelle, welche Datei wohin ginge – **ohne etwas zu **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} ### Jede Person in einen eigenen Ordner {#beispiel-unterordner}
Scannt jede Person in einen eigenen Unterordner (`Scans/max`, `Scans/erika`): pro 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} ### Kopie als zusätzliche Sicherung {#beispiel-kopie}
+1 -1
View File
@@ -1,3 +1,3 @@
"""NetMover – NFS/SMB-Mounts verwalten und Dateien zeitgesteuert verschieben.""" """NetMover – NFS/SMB-Mounts verwalten und Dateien zeitgesteuert verschieben."""
__version__ = "0.8.0" __version__ = "0.9.0"
+10
View File
@@ -668,6 +668,16 @@ def parse_rule_form(s: Session, form: Mapping[str, str]) -> tuple[RuleInput, Err
return d, errors 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]: 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)) dup = s.scalar(select(Rule.id).where(Rule.name == d.name))
if dup is not None and dup != rule_id: if dup is not None and dup != rule_id:
+29 -4
View File
@@ -44,6 +44,7 @@ from netmover.admin import (
mount_input_from_model, mount_input_from_model,
parse_mount_form, parse_mount_form,
parse_rule_form, parse_rule_form,
rule_copy_name,
rule_input_from_model, rule_input_from_model,
save_mount, save_mount,
save_rule, save_rule,
@@ -902,6 +903,7 @@ def _rule_form(
errors: dict[str, str], errors: dict[str, str],
rule_id: int | None, rule_id: int | None,
status_code: int = 200, status_code: int = 200,
copy_of: str | None = None,
) -> Response: ) -> Response:
with nm(request).session_factory() as s: with nm(request).session_factory() as s:
mounts = s.scalars(select(Mount).order_by(Mount.name)).all() mounts = s.scalars(select(Mount).order_by(Mount.name)).all()
@@ -915,6 +917,7 @@ def _rule_form(
d=d, d=d,
errors=errors, errors=errors,
rule_id=rule_id, rule_id=rule_id,
copy_of=copy_of,
src_mounts=mounts, src_mounts=mounts,
src_options=[("", "– wählen –")] + [(m.id, f"{m.name} ({m.type.value})") for m in 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], 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) 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/new", response_class=HTMLResponse)
@protected.post("/rules/{rule_id}/edit", 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) state = nm(request)
form = _form_dict(await request.form()) form = _form_dict(await request.form())
with session_scope(state.session_factory) as s: 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) d, errors = parse_rule_form(s, form)
r = None r = None
if not errors: if not errors:
try: 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: except AdminError as exc:
errors = {"_": str(exc)} errors = {"_": str(exc)}
if errors or r is None: if errors or r is None:
s.rollback() s.rollback()
return _rule_form(request, d, errors, rule_id, status_code=400) return _rule_form(request, d, errors, rule_id, status_code=400, copy_of=copy_of)
name = r.name name, enabled = r.name, r.enabled
state.scheduler.sync() state.scheduler.sync()
flash(request, f"Regel „{name}“ gespeichert.") 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") return redirect("/rules")
+5 -2
View File
@@ -1,8 +1,11 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "macros.html" import field, select, check %} {% 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 %} {% block content %}
<h1>{{ 'Regel bearbeiten' if rule_id else 'Neue Regel' }}</h1> <h1>{% if rule_id %}Regel bearbeiten{% elif copy_of %}Kopie von „{{ copy_of }}“{% else %}Neue Regel{% endif %}</h1>
{% if copy_of %}<p class="msg info small">Alle Einstellungen stammen aus „{{ copy_of }}“. Die Kopie ist zunächst
<strong>deaktiviert</strong>, 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.</p>{% endif %}
{% if errors.get('_') %}<p class="msg error">{{ errors['_'] }}</p>{% endif %} {% if errors.get('_') %}<p class="msg error">{{ errors['_'] }}</p>{% endif %}
{% if not src_mounts %}<p class="msg info">Zuerst eine Anbindung als Quelle <a href="/mounts/new">anlegen</a>.</p>{% endif %} {% if not src_mounts %}<p class="msg info">Zuerst eine Anbindung als Quelle <a href="/mounts/new">anlegen</a>.</p>{% endif %}
<form class="form" method="post" data-dynamic> <form class="form" method="post" data-dynamic>
+1
View File
@@ -18,6 +18,7 @@
<button class="small" hx-post="/rules/{{ r.id }}/dryrun" hx-target="#dry-{{ r.id }}" hx-swap="innerHTML">Probelauf <span class="htmx-indicator">…</span></button> <button class="small" hx-post="/rules/{{ r.id }}/dryrun" hx-target="#dry-{{ r.id }}" hx-swap="innerHTML">Probelauf <span class="htmx-indicator">…</span></button>
<form class="inline" method="post" action="/rules/{{ r.id }}/toggle">{{ csrf_field(csrf) }}<button class="small">{{ 'Deaktivieren' if r.enabled else 'Aktivieren' }}</button></form> <form class="inline" method="post" action="/rules/{{ r.id }}/toggle">{{ csrf_field(csrf) }}<button class="small">{{ 'Deaktivieren' if r.enabled else 'Aktivieren' }}</button></form>
<a class="btn small" href="/rules/{{ r.id }}/edit">Bearbeiten</a> <a class="btn small" href="/rules/{{ r.id }}/edit">Bearbeiten</a>
<a class="btn small" href="/rules/{{ r.id }}/copy" title="Neue Regel mit denselben Einstellungen">Kopieren</a>
<form class="inline" method="post" action="/rules/{{ r.id }}/delete" data-confirm="Regel „{{ r.name }}“ und ihr Protokoll löschen?">{{ csrf_field(csrf) }}<button class="small danger">Löschen</button></form> <form class="inline" method="post" action="/rules/{{ r.id }}/delete" data-confirm="Regel „{{ r.name }}“ und ihr Protokoll löschen?">{{ csrf_field(csrf) }}<button class="small danger">Löschen</button></form>
</div> </div>
</div> </div>
+168
View File
@@ -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'<input[^>]*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'<select[^>]*name="([^"]+)"[^>]*>(.*?)</select>', html, re.S):
sel = re.search(r'<option value="([^"]*)" selected>', m.group(2))
form[m.group(1)] = sel.group(1) if sel else ""
return form
def test_copy_form_is_prefilled_and_disabled(
client: TestClient, state: Any, tmp_path: Path
) -> None:
rid, _ = _setup(state, tmp_path)
assert f'href="/rules/{rid}/copy"' in client.get("/rules").text
html = client.get(f"/rules/{rid}/copy").text
assert "Kopie von „Scans → Paperless“" in html and "deaktiviert" in html
form = _form_from_page(html)
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"
def test_copy_saves_new_rule_with_all_settings(
client: TestClient, state: Any, tmp_path: Path
) -> None:
rid, _ = _setup(state, tmp_path)
form = _form_from_page(client.get(f"/rules/{rid}/copy").text)
form["src_subpath"] = "erika" # typische Anpassung
r = post(client, f"/rules/{rid}/copy", form)
assert r.status_code == 303, r.text
assert "Die Kopie ist deaktiviert" in client.get("/rules").text
orig, copy = _rule(state, "Scans → Paperless"), _rule(state, "Scans → Paperless (Kopie)")
assert copy.id != orig.id and copy.enabled is False and orig.enabled is True
assert copy.src_subpath == "erika" and orig.src_subpath == "max"
for f in (
"src_mount_id",
"dst_path",
"pattern",
"recursive",
"interval_seconds",
"stability_seconds",
"action",
"set_mode",
"quarantine_path",
"archive_kind",
"archive_mount_id",
"archive_path",
"archive_layout",
):
assert getattr(copy, f) == getattr(orig, f), f
# Protokoll und Buchführung beginnen neu
with state.session_factory() as s:
assert s.scalars(select(Run).where(Run.rule_id == copy.id)).all() == []
assert s.scalars(select(CopiedFile).where(CopiedFile.rule_id == copy.id)).all() == []
assert state.scheduler.next_run(copy.id) is None # deaktiviert → kein Termin
def test_copy_names_count_up(client: TestClient, state: Any, tmp_path: Path) -> None:
rid, _ = _setup(state, tmp_path)
for expected in ("Scans → Paperless (Kopie)", "Scans → Paperless (Kopie 2)"):
form = _form_from_page(client.get(f"/rules/{rid}/copy").text)
assert form["name"] == expected
post(client, f"/rules/{rid}/copy", form)
def test_copy_validation_error_keeps_context(
client: TestClient, state: Any, tmp_path: Path
) -> None:
rid, _ = _setup(state, tmp_path)
form = _form_from_page(client.get(f"/rules/{rid}/copy").text)
form["name"] = "Scans → Paperless" # schon vergeben
r = post(client, f"/rules/{rid}/copy", form)
assert r.status_code == 400 and "bereits vergeben" in r.text and "Kopie von" in r.text
with state.session_factory() as s:
assert len(s.scalars(select(Rule)).all()) == 1
def test_copy_of_missing_rule_redirects(client: TestClient) -> None:
assert client.get("/rules/4711/copy").headers["location"] == "/rules"