From c717d6806b3d1bf01809f26ffd3847c0ef9907cd Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Sun, 21 Jun 2026 18:09:04 +0200 Subject: [PATCH] feat: allow deleting housekeeper tasks --- src/ccma/assets/CHANGELOG.json | 1 + src/ccma/domain/models.py | 1 + src/ccma/services/housekeeper.py | 32 ++++++++++++++++++++++++- src/ccma/ui/main_window.py | 7 ++++++ src/ccma/ui/work_tabs.py | 31 ++++++++++++++++++++++++- tests/test_rules.py | 40 ++++++++++++++++++++++++++++++++ 6 files changed, 110 insertions(+), 2 deletions(-) diff --git a/src/ccma/assets/CHANGELOG.json b/src/ccma/assets/CHANGELOG.json index 5d72e27..6eebccb 100644 --- a/src/ccma/assets/CHANGELOG.json +++ b/src/ccma/assets/CHANGELOG.json @@ -22,6 +22,7 @@ "Beschädigte Beitragsdateien blockieren die Mitgliederansicht nicht mehr und werden vom Hausmeister ohne automatisches Überschreiben gemeldet.", "Ein Akten-Preflight sperrt bei beschädigten Mitglieder-, Beitrags- oder Eventdateien alle Regeln für die betroffene Akte.", "Der Hausmeister-Tab zeigt den vollständigen Inhalt eines markierten Vorgangs in einem mehrzeiligen Detailbereich.", + "Hausmeister-Tasks lassen sich manuell löschen; Einträge entfernter Mitgliederakten werden beim nächsten Lauf bereinigt.", "Hausmeister um konfigurierbare Geburtstags- und Mitgliedsjubiläumsmeldungen erweitert.", "Statusänderungen werden mit altem und neuem Klartextwert in der Mitgliederchronik protokolliert.", "Fensterposition, normaler Fensterzustand und Maximierung werden gespeichert; der Splash startet auf dem zuletzt verwendeten Monitor.", diff --git a/src/ccma/domain/models.py b/src/ccma/domain/models.py index 87576f3..bb34742 100644 --- a/src/ccma/domain/models.py +++ b/src/ccma/domain/models.py @@ -166,6 +166,7 @@ class HousekeeperFinding: title: str detail: str due_date: date | None = None + key: str = "" def money(value: str | int | float | Decimal) -> Decimal: diff --git a/src/ccma/services/housekeeper.py b/src/ccma/services/housekeeper.py index 210cd37..79b0935 100644 --- a/src/ccma/services/housekeeper.py +++ b/src/ccma/services/housekeeper.py @@ -64,10 +64,12 @@ class Housekeeper: now = datetime.now().astimezone().isoformat(timespec="seconds") items = _items_by_key(working) successful_scopes: set[tuple[str, str]] = set() + member_ids = set(self.repository.list_member_ids()) + _remove_orphaned_member_items(items, member_ids) rules = load_rules(self.repository.root) repository_config = self.repository.get_configuration() - for member_id in self.repository.list_member_ids(): + for member_id in sorted(member_ids): try: member, contributions = self.repository.preflight_member_record(member_id) except RepositoryError as exc: @@ -126,6 +128,23 @@ class Housekeeper: write_json_atomic(self.state_path, working) return _open_findings(working["items"]) + def delete_task(self, key: str) -> list[HousekeeperFinding]: + selected_key = key.strip() + if not selected_key: + raise RepositoryError("Der Task hat keinen gültigen Key.") + with _exclusive_lock(self.lock_path): + state = self._load_state() + items = _items_by_key(state) + item = items.get(selected_key) + if item is None: + return _open_findings(list(items.values())) + if item.get("action") != "task": + raise RepositoryError("Nur Hausmeister-Tasks können manuell gelöscht werden.") + del items[selected_key] + state["items"] = sorted(items.values(), key=lambda value: str(value.get("key", ""))) + write_json_atomic(self.state_path, state) + return _open_findings(state["items"]) + @staticmethod def _refresh_record_integrity_task( items: dict[str, dict[str, Any]], @@ -351,6 +370,7 @@ def _open_findings(items: list[dict[str, Any]]) -> list[HousekeeperFinding]: title=str(item.get("title", item.get("key", "Hausmeister"))), detail=str(item.get("detail", "")), due_date=due_date, + key=str(item.get("key", "")), ) ) severity_order = {"error": 0, "warning": 1, "info": 2} @@ -360,6 +380,16 @@ def _open_findings(items: list[dict[str, Any]]) -> list[HousekeeperFinding]: ) +def _remove_orphaned_member_items(items: dict[str, dict[str, Any]], member_ids: set[str]) -> None: + orphaned_keys = [ + key + for key, item in items.items() + if item.get("member_id") and str(item["member_id"]) not in member_ids + ] + for key in orphaned_keys: + del items[key] + + @contextmanager def _exclusive_lock(path: Path): path.parent.mkdir(parents=True, exist_ok=True) diff --git a/src/ccma/ui/main_window.py b/src/ccma/ui/main_window.py index c9427e5..79eac90 100644 --- a/src/ccma/ui/main_window.py +++ b/src/ccma/ui/main_window.py @@ -320,6 +320,7 @@ class MainWindow(ttk.Frame): self.findings, self.open_member, self.run_housekeeper, + self.delete_housekeeper_task, lambda: self.tabs.close(key), ) self.tabs.add( @@ -336,6 +337,12 @@ class MainWindow(ttk.Frame): self.status_var.set(f"Hausmeisterlauf beendet: {len(self.findings)} Vorgänge.") return self.findings + def delete_housekeeper_task(self, key: str) -> list[HousekeeperFinding]: + self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).delete_task(key) + self.refresh_overview(run_housekeeper=False) + self.status_var.set("Hausmeister-Task gelöscht.") + return self.findings + def refresh_overview(self, *, run_housekeeper: bool = True) -> None: if run_housekeeper: self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).run() diff --git a/src/ccma/ui/work_tabs.py b/src/ccma/ui/work_tabs.py index 82a4455..e843c58 100644 --- a/src/ccma/ui/work_tabs.py +++ b/src/ccma/ui/work_tabs.py @@ -1,7 +1,7 @@ import tkinter as tk from collections import Counter from collections.abc import Callable -from tkinter import ttk +from tkinter import messagebox, ttk from ccma.domain.dates import format_date_for_display from ccma.domain.models import MEMBERSHIP_STATUS_LABELS, HousekeeperFinding, Member @@ -188,12 +188,14 @@ class HousekeeperTab(ttk.Frame): findings: list[HousekeeperFinding], on_open_member: Callable[[str], None], on_refresh: Callable[[], list[HousekeeperFinding]], + on_delete: Callable[[str], list[HousekeeperFinding]], on_close: Callable[[], None], ): super().__init__(master, padding=12) self.findings = findings self.on_open_member = on_open_member self.on_refresh = on_refresh + self.on_delete = on_delete self.on_close = on_close self._build_ui() @@ -241,6 +243,13 @@ class HousekeeperTab(ttk.Frame): style="Status.TLabel", ) self.detail_label.grid(row=0, column=0, sticky="ew") + self.delete_button = ttk.Button( + details, + text="Task löschen", + command=self._delete_selected, + state="disabled", + ) + self.delete_button.grid(row=0, column=1, sticky="ne", padx=(12, 0)) details.bind( "", lambda event: self.detail_label.configure(wraplength=max(300, event.width - 32)), @@ -254,6 +263,7 @@ class HousekeeperTab(ttk.Frame): def _render(self) -> None: self.tree.delete(*self.tree.get_children()) self.detail_var.set("Eintrag auswählen, um Details anzuzeigen.") + self.delete_button.configure(state="disabled") self.title_var.set(f"HAUSMEISTER · {len(self.findings)} Vorgänge") for index, finding in enumerate(self.findings): self.tree.insert( @@ -267,11 +277,28 @@ class HousekeeperTab(ttk.Frame): selected = self.tree.selection() if not selected: self.detail_var.set("Eintrag auswählen, um Details anzuzeigen.") + self.delete_button.configure(state="disabled") return index = int(selected[0]) if index >= len(self.findings): return self.detail_var.set(_finding_details(self.findings[index])) + self.delete_button.configure(state="normal" if self.findings[index].key else "disabled") + + def _delete_selected(self) -> None: + selected = self.tree.selection() + if not selected: + return + finding = self.findings[int(selected[0])] + if not finding.key or not messagebox.askyesno( + "Hausmeister-Task löschen", + f"Task wirklich löschen?\n\n{finding.title}\n\n" + "Falls die Ursache weiter besteht, wird er beim nächsten Lauf neu angelegt.", + parent=self, + ): + return + self.findings = self.on_delete(finding.key) + self._render() def _open_selected(self) -> None: selected = self.tree.selection() @@ -281,6 +308,8 @@ class HousekeeperTab(ttk.Frame): def _finding_details(finding: HousekeeperFinding) -> str: lines = [f"{finding.severity.upper()} · {finding.code}", finding.title] + if finding.key: + lines.append(f"Key: {finding.key}") if finding.due_date: lines.append(f"Fällig: {format_date_for_display(finding.due_date.isoformat())}") if finding.detail: diff --git a/tests/test_rules.py b/tests/test_rules.py index d37f1c9..2617e31 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -1,4 +1,5 @@ import json +import shutil from datetime import date import pytest @@ -180,3 +181,42 @@ def test_preflight_task_resolves_after_record_is_repaired(tmp_path) -> None: assert task["status"] == "resolved" assert task["resolved_run"] == "2026-06-21:000002" + + +def test_housekeeper_task_can_be_deleted_and_returns_on_next_run(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + repository.create_member(first_name="Manual", last_name="Delete") + housekeeper = Housekeeper(repository) + findings = housekeeper.run(today=date(2026, 6, 21)) + finding = next(item for item in findings if item.code == "missing_birth_date") + + remaining = housekeeper.delete_task(finding.key) + state_after_delete = json.loads((repository.root / "hausmeister.json").read_text(encoding="utf-8")) + + assert remaining == [] + assert state_after_delete["run_counter"] == 1 + assert state_after_delete["items"] == [] + + recreated = housekeeper.run(today=date(2026, 6, 21)) + recreated_finding = next(item for item in recreated if item.code == "missing_birth_date") + state_after_run = json.loads((repository.root / "hausmeister.json").read_text(encoding="utf-8")) + recreated_task = next(item for item in state_after_run["items"] if item["key"] == recreated_finding.key) + + assert recreated_finding.key == finding.key + assert recreated_task["first_seen_run"] == "2026-06-21:000002" + + +def test_housekeeper_removes_items_for_deleted_member_directory(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Deleted", last_name="Member") + housekeeper = Housekeeper(repository) + assert housekeeper.run(today=date(2026, 6, 21)) + shutil.rmtree(repository.members_root / member.member_id) + + findings = housekeeper.run(today=date(2026, 6, 21)) + state = json.loads((repository.root / "hausmeister.json").read_text(encoding="utf-8")) + + assert findings == [] + assert not any(item.get("member_id") == member.member_id for item in state["items"])