mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-25 15:05:18 +02:00
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:
co-authored by
Claude Sonnet 5
parent
c07bdff04e
commit
ee99577b20
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user