From ee99577b207c38bcdc3c63197d340e85badfefac Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Sat, 15 Aug 2026 00:52:32 +0200 Subject: [PATCH 1/3] Run the housekeeper for a single member, optionally retroactively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/ccma/services/housekeeper.py | 66 ++++++++++++++++++---------- src/ccma/ui/main_window.py | 11 +++++ src/ccma/ui/member_tab.py | 75 ++++++++++++++++++++++++++++++-- tests/test_rules.py | 66 +++++++++++++++++++++++++++- 4 files changed, 191 insertions(+), 27 deletions(-) diff --git a/src/ccma/services/housekeeper.py b/src/ccma/services/housekeeper.py index 0577257..555f9d1 100644 --- a/src/ccma/services/housekeeper.py +++ b/src/ccma/services/housekeeper.py @@ -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,16 +162,17 @@ class Housekeeper: else: successful_scopes.add(scope) - for asset_id in sorted(asset_ids): - self._refresh_hash_integrity_tasks( - items, - target_type="asset", - target_id=asset_id, - warnings=self.repository.asset_hash_warnings(asset_id), - run_id=run_id, - now=now, - ) - successful_scopes.add(("asset-hash-check", asset_id)) + if member_id is None: + for asset_id in sorted(asset_ids): + self._refresh_hash_integrity_tasks( + items, + target_type="asset", + target_id=asset_id, + warnings=self.repository.asset_hash_warnings(asset_id), + run_id=run_id, + now=now, + ) + successful_scopes.add(("asset-hash-check", asset_id)) self._resolve_stale_tasks(items, successful_scopes, run_id, now) working.update( diff --git a/src/ccma/ui/main_window.py b/src/ccma/ui/main_window.py index caef85e..fc4ab69 100644 --- a/src/ccma/ui/main_window.py +++ b/src/ccma/ui/main_window.py @@ -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) diff --git a/src/ccma/ui/member_tab.py b/src/ccma/ui/member_tab.py index ee8b058..9ccb29c 100644 --- a/src/ccma/ui/member_tab.py +++ b/src/ccma/ui/member_tab.py @@ -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("", 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) diff --git a/tests/test_rules.py b/tests/test_rules.py index dd389e9..bd8746d 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -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 From 46795fc110202edb22dcc8163f0c3c30d800488c Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Sat, 15 Aug 2026 03:12:44 +0200 Subject: [PATCH 2/3] Reword the single-member housekeeper changelog entry so the PR carries a real diff A prior push mixup (feature/member-contribution-overrides' origin ref had briefly advanced to include this branch's own "Document the single-member housekeeper run feature" commit) meant that commit's changelog line already rode along into dev through PR #17 -- without the actual feature code, which only exists here. CI's PR gate expects CHANGELOG.json to differ from dev, but the merged text was already byte-identical, so this branch's PR couldn't pass. Rewording the entry (adding the actual "Mitgliedsakte" entry point) keeps it accurate and gives this branch a real changelog diff again. Co-Authored-By: Claude Sonnet 5 --- src/ccma/assets/CHANGELOG.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ccma/assets/CHANGELOG.json b/src/ccma/assets/CHANGELOG.json index 0b1dddf..d309112 100644 --- a/src/ccma/assets/CHANGELOG.json +++ b/src/ccma/assets/CHANGELOG.json @@ -29,7 +29,7 @@ "Der GnuCash-Import öffnet sich mit 80 % der Bildschirmgröße und erlaubt die Mehrfachauswahl von Buchungen per Klick, Strg+Klick oder Umschalt+Klick; Buchungen, die einer bereits vorhandenen Zahlung entsprechen, können beim Import übersprungen oder mit der Beschreibung aus GnuCash aktualisiert werden.", "Beim Anklicken einer noch nicht zugeordneten Forderung oder Spende im Zahlungsfenster wird der Betrag automatisch mit dem sinnvollen Vorschlag vorausgefüllt.", "Individuell abweichende Mitgliedsbeiträge (z. B. ermäßigter Beitrag für Schüler) können pro Mitglied mit Zeitraum, Begründung und wahlweise als fester Jahresbetrag oder prozentualer Ermäßigung hinterlegt werden; künftig erzeugte Beitragsforderungen berücksichtigen das monatsgenau, auch wenn die Ermäßigung mitten in einer Zahlungsperiode beginnt oder endet.", - "Der Hausmeister kann gezielt für ein einzelnes Mitglied ausgeführt werden, optional mit rückwirkender Erstellung von Beiträgen/Forderungen nur für diesen einen Lauf; alle anderen Mitglieder bleiben davon unberührt." + "Der Hausmeister kann per Knopfdruck direkt aus der Mitgliederakte gezielt für ein einzelnes Mitglied ausgeführt werden, optional mit rückwirkender Erstellung von Beiträgen/Forderungen nur für diesen einen Lauf; alle anderen Mitglieder bleiben davon unberührt." ] }, { From a1fb2f4e30afd4e6e9e93bf7a0967468508ec523 Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Sat, 15 Aug 2026 03:40:40 +0200 Subject: [PATCH 3/3] Refresh the member tab after a single-member housekeeper run _execute_housekeeper_run() showed the success dialog but never called refresh()/on_changed(), so retroactively created claims/payments stayed invisible in the currently open Mitgliedsakte until it was closed and reopened, and the overview list wasn't notified either. Match the pattern used by every other mutating action in this file (save, payment, credit, reminder, ...): refresh the tab's own contribution lists and notify the overview callback right after the run succeeds. Co-Authored-By: Claude Sonnet 5 --- src/ccma/ui/member_tab.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ccma/ui/member_tab.py b/src/ccma/ui/member_tab.py index 9ccb29c..f599045 100644 --- a/src/ccma/ui/member_tab.py +++ b/src/ccma/ui/member_tab.py @@ -629,6 +629,8 @@ class MemberTab(ttk.Frame): f"{len(member_findings)} offene(r) Vorgang/Vorgänge für dieses Mitglied.", parent=self, ) + self.refresh() + self.on_changed() def _build_timeline(self, parent: ttk.Frame) -> None: parent.columnconfigure(0, weight=1)