mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-07-01 19:26:53 +02:00
feat: allow deleting housekeeper tasks
This commit is contained in:
@@ -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.",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
Reference in New Issue
Block a user