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