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
+34 -12
View File
@@ -6,7 +6,7 @@ import math
import os
import time
from contextlib import contextmanager
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from datetime import date, datetime
from pathlib import Path
from typing import Any
@@ -67,9 +67,23 @@ class Housekeeper:
today: date | None = None,
*,
member_delay: float = 0.0,
member_id: str | None = None,
retroactive_claims: bool | None = None,
) -> list[HousekeeperFinding]:
"""Run all rules. With `member_id`, only that member is (re-)evaluated --
asset checks are skipped and every other member's tasks are left untouched
(their rules never ran this pass, so they fall outside `successful_scopes`
and `_resolve_stale_tasks` can't touch them). `retroactive_claims`, if given,
overrides the configured setting for just this run without changing it
permanently -- e.g. to recompute one member's full claim history on demand
after a contribution change, without retroactively billing everyone else."""
current_date = today or date.today()
delay = _non_negative_delay(member_delay)
effective_settings = (
self.settings
if retroactive_claims is None
else replace(self.settings, retroactive_claims=retroactive_claims)
)
with _exclusive_lock(self.lock_path):
original = self._load_state()
working = copy.deepcopy(original)
@@ -78,32 +92,39 @@ 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())
all_member_ids = set(self.repository.list_member_ids())
asset_ids = set(self.repository.list_asset_ids())
_remove_orphaned_member_items(items, member_ids)
_remove_orphaned_member_items(items, all_member_ids)
_remove_orphaned_asset_items(items, asset_ids)
if member_id is not None:
if member_id not in all_member_ids:
raise RepositoryError(f"Mitglied nicht gefunden: {member_id}")
selected_member_ids = {member_id}
else:
selected_member_ids = all_member_ids
rules = load_rules(self.repository.root)
repository_config = self.repository.get_configuration()
for index, member_id in enumerate(sorted(member_ids)):
for index, current_member_id in enumerate(sorted(selected_member_ids)):
if index and delay:
time.sleep(delay)
try:
member, contributions = self.repository.preflight_member_record(member_id)
member, contributions = self.repository.preflight_member_record(current_member_id)
except RepositoryError as exc:
self._refresh_record_integrity_task(items, member_id, str(exc), run_id, now)
successful_scopes.add(("member-record-check", member_id))
self._refresh_record_integrity_task(items, current_member_id, str(exc), run_id, now)
successful_scopes.add(("member-record-check", current_member_id))
continue
successful_scopes.add(("member-record-check", member_id))
successful_scopes.add(("member-record-check", current_member_id))
self._refresh_hash_integrity_tasks(
items,
target_type="member",
target_id=member_id,
warnings=self.repository.member_hash_warnings(member_id),
target_id=current_member_id,
warnings=self.repository.member_hash_warnings(current_member_id),
run_id=run_id,
now=now,
)
successful_scopes.add(("member-hash-check", member_id))
successful_scopes.add(("member-hash-check", current_member_id))
for rule in rules:
scope = (rule.rule_id, member.member_id)
try:
@@ -111,7 +132,7 @@ class Housekeeper:
member=member,
contributions=contributions,
today=current_date,
settings=self.settings,
settings=effective_settings,
repository_config=repository_config,
)
actions = rule.evaluate(context)
@@ -141,6 +162,7 @@ class Housekeeper:
else:
successful_scopes.add(scope)
if member_id is None:
for asset_id in sorted(asset_ids):
self._refresh_hash_integrity_tasks(
items,
+11
View File
@@ -302,6 +302,7 @@ class MainWindow(ttk.Frame):
on_open_assets=self.open_assets,
on_open_asset=self.open_asset,
on_return_asset=self.return_asset,
on_run_housekeeper=self.run_housekeeper_for_member,
)
self.tabs.add(
key,
@@ -503,6 +504,16 @@ class MainWindow(ttk.Frame):
self.status_var.set(f"Hausmeisterlauf beendet: {len(self.findings)} Vorgänge.")
return self.findings
def run_housekeeper_for_member(
self, member_id: str, retroactive_claims: bool = False
) -> list[HousekeeperFinding]:
self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).run(
member_id=member_id, retroactive_claims=retroactive_claims or None
)
self.refresh_overview(run_housekeeper=False)
self.status_var.set(f"Hausmeisterlauf für Mitglied beendet: {len(self.findings)} Vorgänge insgesamt.")
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)
+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)
+65 -1
View File
@@ -8,7 +8,7 @@ import pytest
import ccma.services.housekeeper as housekeeper_module
from ccma.rules.loader import RuleLoadError
from ccma.services.housekeeper import Housekeeper
from ccma.storage.repository import MemberRepository
from ccma.storage.repository import MemberRepository, RepositoryError
def test_housekeeper_optionally_waits_between_members(tmp_path, monkeypatch) -> None:
@@ -381,3 +381,67 @@ def test_housekeeper_removes_items_for_deleted_member_directory(tmp_path) -> Non
assert findings == []
assert not any(item.get("member_id") == member.member_id for item in state["items"])
def test_housekeeper_run_can_target_a_single_member(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member_a = repository.create_member(first_name="Alpha", last_name="Member")
member_b = repository.create_member(first_name="Beta", last_name="Member")
housekeeper = Housekeeper(repository)
housekeeper.run(today=date(2026, 6, 21))
state_before = json.loads((repository.root / "housekeeper.json").read_text(encoding="utf-8"))
b_task_before = next(
item for item in state_before["items"] if item.get("member_id") == member_b.member_id
)
member_a.birth_date = "1990-01-01"
repository.save_member(member_a)
findings = housekeeper.run(today=date(2026, 6, 21), member_id=member_a.member_id)
a_findings = [item for item in findings if item.member_id == member_a.member_id]
assert not any(item.code == "missing_birth_date" for item in a_findings)
state_after = json.loads((repository.root / "housekeeper.json").read_text(encoding="utf-8"))
b_task_after = next(
item for item in state_after["items"] if item.get("member_id") == member_b.member_id
)
assert b_task_after == b_task_before
def test_housekeeper_run_rejects_unknown_member_id(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
housekeeper = Housekeeper(repository)
with pytest.raises(RepositoryError, match="nicht gefunden"):
housekeeper.run(today=date(2026, 6, 21), member_id="does-not-exist")
def test_housekeeper_run_retroactive_override_applies_only_to_that_run(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Retro", last_name="Member", birth_date="1990-01-01")
member.status = "active"
member.accepted_at = "2024-04-15"
member.membership_started_at = "2024-04-15"
member.payment_frequency = "annual"
repository.save_member(member)
housekeeper = Housekeeper(repository)
housekeeper.run(today=date(2026, 6, 21), member_id=member.member_id, retroactive_claims=True)
claim_keys = {claim["claim_key"] for claim in repository.get_contributions(member.member_id).claims}
assert "membership-fee:2024:annual" in claim_keys
assert housekeeper.settings.retroactive_claims is False
other = repository.create_member(first_name="NoRetro", last_name="Member", birth_date="1990-01-01")
other.status = "active"
other.accepted_at = "2024-04-15"
other.membership_started_at = "2024-04-15"
other.payment_frequency = "annual"
repository.save_member(other)
housekeeper.run(today=date(2026, 6, 21), member_id=other.member_id)
other_claim_keys = {claim["claim_key"] for claim in repository.get_contributions(other.member_id).claims}
assert "membership-fee:2024:annual" not in other_claim_keys
assert "membership-fee:2026:annual" in other_claim_keys