mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-25 06:55:17 +02:00
Merge pull request 'Feature/housekeeper run single member' (#18) from feature/housekeeper-run-single-member into dev
Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/18 Reviewed-by: Matcha <20+matcha@noreply.git.hiabuto.net>
This commit is contained in:
@@ -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."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,25 @@ 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,
|
||||
)
|
||||
self.refresh()
|
||||
self.on_changed()
|
||||
|
||||
def _build_timeline(self, parent: ttk.Frame) -> None:
|
||||
parent.columnconfigure(0, weight=1)
|
||||
parent.rowconfigure(1, weight=1)
|
||||
|
||||
+65
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user