feat: allow deleting housekeeper tasks

This commit is contained in:
Marcel Peterkau
2026-06-21 18:09:04 +02:00
parent 3e9f347435
commit c717d6806b
6 changed files with 110 additions and 2 deletions
+1
View File
@@ -22,6 +22,7 @@
"Beschädigte Beitragsdateien blockieren die Mitgliederansicht nicht mehr und werden vom Hausmeister ohne automatisches Überschreiben gemeldet.", "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.", "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.", "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.", "Hausmeister um konfigurierbare Geburtstags- und Mitgliedsjubiläumsmeldungen erweitert.",
"Statusänderungen werden mit altem und neuem Klartextwert in der Mitgliederchronik protokolliert.", "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.", "Fensterposition, normaler Fensterzustand und Maximierung werden gespeichert; der Splash startet auf dem zuletzt verwendeten Monitor.",
+1
View File
@@ -166,6 +166,7 @@ class HousekeeperFinding:
title: str title: str
detail: str detail: str
due_date: date | None = None due_date: date | None = None
key: str = ""
def money(value: str | int | float | Decimal) -> Decimal: def money(value: str | int | float | Decimal) -> Decimal:
+31 -1
View File
@@ -64,10 +64,12 @@ class Housekeeper:
now = datetime.now().astimezone().isoformat(timespec="seconds") now = datetime.now().astimezone().isoformat(timespec="seconds")
items = _items_by_key(working) items = _items_by_key(working)
successful_scopes: set[tuple[str, str]] = set() 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) rules = load_rules(self.repository.root)
repository_config = self.repository.get_configuration() repository_config = self.repository.get_configuration()
for member_id in self.repository.list_member_ids(): for member_id in sorted(member_ids):
try: try:
member, contributions = self.repository.preflight_member_record(member_id) member, contributions = self.repository.preflight_member_record(member_id)
except RepositoryError as exc: except RepositoryError as exc:
@@ -126,6 +128,23 @@ class Housekeeper:
write_json_atomic(self.state_path, working) write_json_atomic(self.state_path, working)
return _open_findings(working["items"]) 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 @staticmethod
def _refresh_record_integrity_task( def _refresh_record_integrity_task(
items: dict[str, dict[str, Any]], 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"))), title=str(item.get("title", item.get("key", "Hausmeister"))),
detail=str(item.get("detail", "")), detail=str(item.get("detail", "")),
due_date=due_date, due_date=due_date,
key=str(item.get("key", "")),
) )
) )
severity_order = {"error": 0, "warning": 1, "info": 2} 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 @contextmanager
def _exclusive_lock(path: Path): def _exclusive_lock(path: Path):
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
+7
View File
@@ -320,6 +320,7 @@ class MainWindow(ttk.Frame):
self.findings, self.findings,
self.open_member, self.open_member,
self.run_housekeeper, self.run_housekeeper,
self.delete_housekeeper_task,
lambda: self.tabs.close(key), lambda: self.tabs.close(key),
) )
self.tabs.add( self.tabs.add(
@@ -336,6 +337,12 @@ class MainWindow(ttk.Frame):
self.status_var.set(f"Hausmeisterlauf beendet: {len(self.findings)} Vorgänge.") self.status_var.set(f"Hausmeisterlauf beendet: {len(self.findings)} Vorgänge.")
return self.findings 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: def refresh_overview(self, *, run_housekeeper: bool = True) -> None:
if run_housekeeper: if run_housekeeper:
self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).run() self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).run()
+30 -1
View File
@@ -1,7 +1,7 @@
import tkinter as tk import tkinter as tk
from collections import Counter from collections import Counter
from collections.abc import Callable 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.dates import format_date_for_display
from ccma.domain.models import MEMBERSHIP_STATUS_LABELS, HousekeeperFinding, Member from ccma.domain.models import MEMBERSHIP_STATUS_LABELS, HousekeeperFinding, Member
@@ -188,12 +188,14 @@ class HousekeeperTab(ttk.Frame):
findings: list[HousekeeperFinding], findings: list[HousekeeperFinding],
on_open_member: Callable[[str], None], on_open_member: Callable[[str], None],
on_refresh: Callable[[], list[HousekeeperFinding]], on_refresh: Callable[[], list[HousekeeperFinding]],
on_delete: Callable[[str], list[HousekeeperFinding]],
on_close: Callable[[], None], on_close: Callable[[], None],
): ):
super().__init__(master, padding=12) super().__init__(master, padding=12)
self.findings = findings self.findings = findings
self.on_open_member = on_open_member self.on_open_member = on_open_member
self.on_refresh = on_refresh self.on_refresh = on_refresh
self.on_delete = on_delete
self.on_close = on_close self.on_close = on_close
self._build_ui() self._build_ui()
@@ -241,6 +243,13 @@ class HousekeeperTab(ttk.Frame):
style="Status.TLabel", style="Status.TLabel",
) )
self.detail_label.grid(row=0, column=0, sticky="ew") 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( details.bind(
"<Configure>", "<Configure>",
lambda event: self.detail_label.configure(wraplength=max(300, event.width - 32)), lambda event: self.detail_label.configure(wraplength=max(300, event.width - 32)),
@@ -254,6 +263,7 @@ class HousekeeperTab(ttk.Frame):
def _render(self) -> None: def _render(self) -> None:
self.tree.delete(*self.tree.get_children()) self.tree.delete(*self.tree.get_children())
self.detail_var.set("Eintrag auswählen, um Details anzuzeigen.") 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") self.title_var.set(f"HAUSMEISTER · {len(self.findings)} Vorgänge")
for index, finding in enumerate(self.findings): for index, finding in enumerate(self.findings):
self.tree.insert( self.tree.insert(
@@ -267,11 +277,28 @@ class HousekeeperTab(ttk.Frame):
selected = self.tree.selection() selected = self.tree.selection()
if not selected: if not selected:
self.detail_var.set("Eintrag auswählen, um Details anzuzeigen.") self.detail_var.set("Eintrag auswählen, um Details anzuzeigen.")
self.delete_button.configure(state="disabled")
return return
index = int(selected[0]) index = int(selected[0])
if index >= len(self.findings): if index >= len(self.findings):
return return
self.detail_var.set(_finding_details(self.findings[index])) 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: def _open_selected(self) -> None:
selected = self.tree.selection() selected = self.tree.selection()
@@ -281,6 +308,8 @@ class HousekeeperTab(ttk.Frame):
def _finding_details(finding: HousekeeperFinding) -> str: def _finding_details(finding: HousekeeperFinding) -> str:
lines = [f"{finding.severity.upper()} · {finding.code}", finding.title] lines = [f"{finding.severity.upper()} · {finding.code}", finding.title]
if finding.key:
lines.append(f"Key: {finding.key}")
if finding.due_date: if finding.due_date:
lines.append(f"Fällig: {format_date_for_display(finding.due_date.isoformat())}") lines.append(f"Fällig: {format_date_for_display(finding.due_date.isoformat())}")
if finding.detail: if finding.detail:
+40
View File
@@ -1,4 +1,5 @@
import json import json
import shutil
from datetime import date from datetime import date
import pytest import pytest
@@ -180,3 +181,42 @@ def test_preflight_task_resolves_after_record_is_repaired(tmp_path) -> None:
assert task["status"] == "resolved" assert task["status"] == "resolved"
assert task["resolved_run"] == "2026-06-21:000002" 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"])