Run the housekeeper for a single member, optionally retroactively

Add a "Hausmeister für dieses Mitglied" button to the member view. It
runs every rule scoped to just that member -- e.g. after editing a
contribution override or payment frequency, to recompute their claims on
demand instead of waiting for or triggering a full run over every
member. A checkbox lets the board force retroactive claim creation for
just that one run, without changing the application-wide default
setting.

Housekeeper.run() gained member_id and retroactive_claims parameters.
Scoping to one member skips the asset checks and leaves every other
member's tasks completely untouched, since their rules never ran this
pass and so fall outside the resolved-scope set that
_resolve_stale_tasks uses to decide what to clear.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marcel Peterkau
2026-08-15 00:59:01 +02:00
co-authored by Claude Sonnet 5
parent c07bdff04e
commit ee99577b20
4 changed files with 191 additions and 27 deletions
+71 -4
View File
@@ -21,7 +21,7 @@ from ccma.domain.contributions import (
payment_allocated_total,
)
from ccma.domain.dates import age_label, date_input_hint, format_date_for_display, format_month_for_display
from ccma.domain.models import ASSET_STATUS_LABELS, PAYMENT_FREQUENCY_LABELS, Event
from ccma.domain.models import ASSET_STATUS_LABELS, PAYMENT_FREQUENCY_LABELS, Event, HousekeeperFinding
from ccma.domain.models import MEMBERSHIP_STATUS_LABELS as STATUS_LABELS
from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.contribution_override_dialog import ContributionOverrideEditDialog
@@ -67,6 +67,49 @@ def _claim_sort_value(data, claim: dict, column: str) -> str:
return ""
class _RunHousekeeperDialog(tk.Toplevel):
"""Confirms a housekeeper run scoped to a single member, with the option to
force retroactive claim creation for just this run without touching the
application-wide default setting."""
def __init__(self, master: tk.Misc, member_name: str, on_confirm: Callable[[bool], None]):
super().__init__(master)
self.on_confirm = on_confirm
self.title("Hausmeister für dieses Mitglied ausführen")
self.transient(master.winfo_toplevel())
self.resizable(False, False)
frame = ttk.Frame(self, padding=18)
frame.pack(fill="both", expand=True)
ttk.Label(
frame,
text=(
f"Alle Regeln werden ausschließlich für {member_name} neu ausgeführt. "
"Andere Mitglieder bleiben unberührt."
),
wraplength=380,
justify="left",
).pack(anchor="w", pady=(0, 12))
self.retroactive_var = tk.BooleanVar(value=False)
ttk.Checkbutton(
frame,
text="Rückwirkende Beiträge/Forderungen erstellen",
variable=self.retroactive_var,
).pack(anchor="w", pady=(0, 14))
buttons = ttk.Frame(frame)
buttons.pack(anchor="e")
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
ttk.Button(buttons, text="Ausführen", style="Accent.TButton", command=self._confirm).pack(
side="left"
)
self.bind("<Escape>", lambda _event: self.destroy())
self.after_idle(self.grab_set)
def _confirm(self) -> None:
retroactive = self.retroactive_var.get()
self.destroy()
self.on_confirm(retroactive)
class MemberTab(ttk.Frame):
def __init__(
self,
@@ -80,6 +123,7 @@ class MemberTab(ttk.Frame):
on_open_assets: Callable[[], None],
on_open_asset: Callable[[str], None],
on_return_asset: Callable[[str], None],
on_run_housekeeper: Callable[[str, bool], list[HousekeeperFinding]],
):
super().__init__(master, padding=12)
self.repository = repository
@@ -91,6 +135,7 @@ class MemberTab(ttk.Frame):
self.on_open_assets = on_open_assets
self.on_open_asset = on_open_asset
self.on_return_asset = on_return_asset
self.on_run_housekeeper = on_run_housekeeper
self.member = repository.get_member(member_id)
self.variables: dict[str, tk.Variable] = {}
self._field_sections: dict[str, str] = {}
@@ -124,9 +169,14 @@ class MemberTab(ttk.Frame):
ttk.Label(title_column, textvariable=self.id_var, style="Mono.TLabel").grid(
row=2, column=0, sticky="w", pady=(3, 0)
)
ttk.Button(header, text="Tab schließen", command=self._close).grid(
row=0, column=1, sticky="ne", padx=(12, 0)
)
header_actions = ttk.Frame(header)
header_actions.grid(row=0, column=1, sticky="ne", padx=(12, 0))
ttk.Button(
header_actions,
text="Hausmeister für dieses Mitglied",
command=self._run_housekeeper_for_member,
).pack(side="left", padx=(0, 8))
ttk.Button(header_actions, text="Tab schließen", command=self._close).pack(side="left")
self.messages = MessageBannerList(self)
self.messages.grid(row=1, column=0, sticky="ew", pady=(0, 10))
self.messages.grid_remove()
@@ -563,6 +613,23 @@ class MemberTab(ttk.Frame):
return
self.on_close()
def _run_housekeeper_for_member(self) -> None:
_RunHousekeeperDialog(self, self.member.display_name, self._execute_housekeeper_run)
def _execute_housekeeper_run(self, retroactive: bool) -> None:
try:
findings = self.on_run_housekeeper(self.member_id, retroactive)
except RepositoryError as exc:
messagebox.showerror("Hausmeisterlauf fehlgeschlagen", str(exc), parent=self)
return
member_findings = [item for item in findings if item.member_id == self.member_id]
messagebox.showinfo(
"Hausmeister ausgeführt",
f"Hausmeisterlauf für {self.member.display_name} abgeschlossen. "
f"{len(member_findings)} offene(r) Vorgang/Vorgänge für dieses Mitglied.",
parent=self,
)
def _build_timeline(self, parent: ttk.Frame) -> None:
parent.columnconfigure(0, weight=1)
parent.rowconfigure(1, weight=1)