mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-26 15:25:19 +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.",
|
"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.",
|
"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.",
|
"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 os
|
||||||
import time
|
import time
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field, replace
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -67,9 +67,23 @@ class Housekeeper:
|
|||||||
today: date | None = None,
|
today: date | None = None,
|
||||||
*,
|
*,
|
||||||
member_delay: float = 0.0,
|
member_delay: float = 0.0,
|
||||||
|
member_id: str | None = None,
|
||||||
|
retroactive_claims: bool | None = None,
|
||||||
) -> list[HousekeeperFinding]:
|
) -> 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()
|
current_date = today or date.today()
|
||||||
delay = _non_negative_delay(member_delay)
|
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):
|
with _exclusive_lock(self.lock_path):
|
||||||
original = self._load_state()
|
original = self._load_state()
|
||||||
working = copy.deepcopy(original)
|
working = copy.deepcopy(original)
|
||||||
@@ -78,32 +92,39 @@ class Housekeeper:
|
|||||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||||
items = _items_by_key(working)
|
items = _items_by_key(working)
|
||||||
successful_scopes: set[tuple[str, str]] = set()
|
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())
|
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)
|
_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)
|
rules = load_rules(self.repository.root)
|
||||||
repository_config = self.repository.get_configuration()
|
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:
|
if index and delay:
|
||||||
time.sleep(delay)
|
time.sleep(delay)
|
||||||
try:
|
try:
|
||||||
member, contributions = self.repository.preflight_member_record(member_id)
|
member, contributions = self.repository.preflight_member_record(current_member_id)
|
||||||
except RepositoryError as exc:
|
except RepositoryError as exc:
|
||||||
self._refresh_record_integrity_task(items, member_id, str(exc), run_id, now)
|
self._refresh_record_integrity_task(items, current_member_id, str(exc), run_id, now)
|
||||||
successful_scopes.add(("member-record-check", member_id))
|
successful_scopes.add(("member-record-check", current_member_id))
|
||||||
continue
|
continue
|
||||||
successful_scopes.add(("member-record-check", member_id))
|
successful_scopes.add(("member-record-check", current_member_id))
|
||||||
self._refresh_hash_integrity_tasks(
|
self._refresh_hash_integrity_tasks(
|
||||||
items,
|
items,
|
||||||
target_type="member",
|
target_type="member",
|
||||||
target_id=member_id,
|
target_id=current_member_id,
|
||||||
warnings=self.repository.member_hash_warnings(member_id),
|
warnings=self.repository.member_hash_warnings(current_member_id),
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
now=now,
|
now=now,
|
||||||
)
|
)
|
||||||
successful_scopes.add(("member-hash-check", member_id))
|
successful_scopes.add(("member-hash-check", current_member_id))
|
||||||
for rule in rules:
|
for rule in rules:
|
||||||
scope = (rule.rule_id, member.member_id)
|
scope = (rule.rule_id, member.member_id)
|
||||||
try:
|
try:
|
||||||
@@ -111,7 +132,7 @@ class Housekeeper:
|
|||||||
member=member,
|
member=member,
|
||||||
contributions=contributions,
|
contributions=contributions,
|
||||||
today=current_date,
|
today=current_date,
|
||||||
settings=self.settings,
|
settings=effective_settings,
|
||||||
repository_config=repository_config,
|
repository_config=repository_config,
|
||||||
)
|
)
|
||||||
actions = rule.evaluate(context)
|
actions = rule.evaluate(context)
|
||||||
@@ -141,6 +162,7 @@ class Housekeeper:
|
|||||||
else:
|
else:
|
||||||
successful_scopes.add(scope)
|
successful_scopes.add(scope)
|
||||||
|
|
||||||
|
if member_id is None:
|
||||||
for asset_id in sorted(asset_ids):
|
for asset_id in sorted(asset_ids):
|
||||||
self._refresh_hash_integrity_tasks(
|
self._refresh_hash_integrity_tasks(
|
||||||
items,
|
items,
|
||||||
|
|||||||
@@ -302,6 +302,7 @@ class MainWindow(ttk.Frame):
|
|||||||
on_open_assets=self.open_assets,
|
on_open_assets=self.open_assets,
|
||||||
on_open_asset=self.open_asset,
|
on_open_asset=self.open_asset,
|
||||||
on_return_asset=self.return_asset,
|
on_return_asset=self.return_asset,
|
||||||
|
on_run_housekeeper=self.run_housekeeper_for_member,
|
||||||
)
|
)
|
||||||
self.tabs.add(
|
self.tabs.add(
|
||||||
key,
|
key,
|
||||||
@@ -503,6 +504,16 @@ class MainWindow(ttk.Frame):
|
|||||||
self.status_var.set(f"Hausmeisterlauf beendet: {len(self.findings)} Vorgänge.")
|
self.status_var.set(f"Hausmeisterlauf beendet: {len(self.findings)} Vorgänge.")
|
||||||
return self.findings
|
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]:
|
def delete_housekeeper_task(self, key: str) -> list[HousekeeperFinding]:
|
||||||
self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).delete_task(key)
|
self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).delete_task(key)
|
||||||
self.refresh_overview(run_housekeeper=False)
|
self.refresh_overview(run_housekeeper=False)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from ccma.domain.contributions import (
|
|||||||
payment_allocated_total,
|
payment_allocated_total,
|
||||||
)
|
)
|
||||||
from ccma.domain.dates import age_label, date_input_hint, format_date_for_display, format_month_for_display
|
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.domain.models import MEMBERSHIP_STATUS_LABELS as STATUS_LABELS
|
||||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||||
from ccma.ui.contribution_override_dialog import ContributionOverrideEditDialog
|
from ccma.ui.contribution_override_dialog import ContributionOverrideEditDialog
|
||||||
@@ -67,6 +67,49 @@ def _claim_sort_value(data, claim: dict, column: str) -> str:
|
|||||||
return ""
|
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):
|
class MemberTab(ttk.Frame):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -80,6 +123,7 @@ class MemberTab(ttk.Frame):
|
|||||||
on_open_assets: Callable[[], None],
|
on_open_assets: Callable[[], None],
|
||||||
on_open_asset: Callable[[str], None],
|
on_open_asset: Callable[[str], None],
|
||||||
on_return_asset: Callable[[str], None],
|
on_return_asset: Callable[[str], None],
|
||||||
|
on_run_housekeeper: Callable[[str, bool], list[HousekeeperFinding]],
|
||||||
):
|
):
|
||||||
super().__init__(master, padding=12)
|
super().__init__(master, padding=12)
|
||||||
self.repository = repository
|
self.repository = repository
|
||||||
@@ -91,6 +135,7 @@ class MemberTab(ttk.Frame):
|
|||||||
self.on_open_assets = on_open_assets
|
self.on_open_assets = on_open_assets
|
||||||
self.on_open_asset = on_open_asset
|
self.on_open_asset = on_open_asset
|
||||||
self.on_return_asset = on_return_asset
|
self.on_return_asset = on_return_asset
|
||||||
|
self.on_run_housekeeper = on_run_housekeeper
|
||||||
self.member = repository.get_member(member_id)
|
self.member = repository.get_member(member_id)
|
||||||
self.variables: dict[str, tk.Variable] = {}
|
self.variables: dict[str, tk.Variable] = {}
|
||||||
self._field_sections: dict[str, str] = {}
|
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(
|
ttk.Label(title_column, textvariable=self.id_var, style="Mono.TLabel").grid(
|
||||||
row=2, column=0, sticky="w", pady=(3, 0)
|
row=2, column=0, sticky="w", pady=(3, 0)
|
||||||
)
|
)
|
||||||
ttk.Button(header, text="Tab schließen", command=self._close).grid(
|
header_actions = ttk.Frame(header)
|
||||||
row=0, column=1, sticky="ne", padx=(12, 0)
|
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 = MessageBannerList(self)
|
||||||
self.messages.grid(row=1, column=0, sticky="ew", pady=(0, 10))
|
self.messages.grid(row=1, column=0, sticky="ew", pady=(0, 10))
|
||||||
self.messages.grid_remove()
|
self.messages.grid_remove()
|
||||||
@@ -563,6 +613,25 @@ class MemberTab(ttk.Frame):
|
|||||||
return
|
return
|
||||||
self.on_close()
|
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:
|
def _build_timeline(self, parent: ttk.Frame) -> None:
|
||||||
parent.columnconfigure(0, weight=1)
|
parent.columnconfigure(0, weight=1)
|
||||||
parent.rowconfigure(1, weight=1)
|
parent.rowconfigure(1, weight=1)
|
||||||
|
|||||||
+65
-1
@@ -8,7 +8,7 @@ import pytest
|
|||||||
import ccma.services.housekeeper as housekeeper_module
|
import ccma.services.housekeeper as housekeeper_module
|
||||||
from ccma.rules.loader import RuleLoadError
|
from ccma.rules.loader import RuleLoadError
|
||||||
from ccma.services.housekeeper import Housekeeper
|
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:
|
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 findings == []
|
||||||
assert not any(item.get("member_id") == member.member_id for item in state["items"])
|
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