fix: preflight member records before rules

This commit is contained in:
Marcel Peterkau
2026-06-21 17:54:09 +02:00
parent 55bc3b666e
commit 7596e47981
10 changed files with 163 additions and 44 deletions
+1
View File
@@ -20,6 +20,7 @@
"Universelle, dateibasierte Hausmeister-Regeln mit atomarem Run-Journal, Task-Lifecycle und idempotenten Forderungsaktionen eingeführt.", "Universelle, dateibasierte Hausmeister-Regeln mit atomarem Run-Journal, Task-Lifecycle und idempotenten Forderungsaktionen eingeführt.",
"Regelskripte im Mitglieder-Store können eingebaute Regeln anhand ihres Dateinamens gezielt ersetzen.", "Regelskripte im Mitglieder-Store können eingebaute Regeln anhand ihres Dateinamens gezielt ersetzen.",
"Beschädigte Beitragsdateien blockieren die Mitgliederansicht nicht mehr und werden vom Hausmeister ohne automatisches Überschreiben gemeldet.", "Beschädigte Beitragsdateien blockieren die Mitgliederansicht nicht mehr und werden vom Hausmeister ohne automatisches Überschreiben gemeldet.",
"Ein Akten-Preflight sperrt bei beschädigten Mitglieder-, Beitrags- oder Eventdateien alle Regeln für die betroffene Akte.",
"Hausmeister um konfigurierbare Geburtstags- und Mitgliedsjubiläumsmeldungen erweitert.", "Hausmeister um konfigurierbare Geburtstags- und Mitgliedsjubiläumsmeldungen erweitert.",
"Statusänderungen werden mit altem und neuem Klartextwert in der Mitgliederchronik protokolliert.", "Statusänderungen werden mit altem und neuem Klartextwert in der Mitgliederchronik protokolliert.",
"Fensterposition, normaler Fensterzustand und Maximierung werden gespeichert; der Splash startet auf dem zuletzt verwendeten Monitor.", "Fensterposition, normaler Fensterzustand und Maximierung werden gespeichert; der Splash startet auf dem zuletzt verwendeten Monitor.",
-1
View File
@@ -15,7 +15,6 @@ class RuleContext:
today: date today: date
settings: Any settings: Any
repository_config: Mapping[str, Any] repository_config: Mapping[str, Any]
contributions_error: str | None = None
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
-2
View File
@@ -7,8 +7,6 @@ ORDER = 50
def evaluate(context: RuleContext): def evaluate(context: RuleContext):
if context.contributions_error:
return []
actions = [] actions = []
for claim in context.contributions.claims: for claim in context.contributions.claims:
if str(claim.get("status", "open")) not in {"open", "partially_paid"}: if str(claim.get("status", "open")) not in {"open", "partially_paid"}:
@@ -12,8 +12,6 @@ CENT = Decimal("0.01")
def evaluate(context: RuleContext): def evaluate(context: RuleContext):
if context.contributions_error:
return []
member = context.member member = context.member
if member.honorary or member.status not in CONTRIBUTION_STATUSES: if member.honorary or member.status not in CONTRIBUTION_STATUSES:
return [] return []
@@ -1,20 +0,0 @@
from ccma.rules.api import RuleContext, task
RULE_ID = "contributions-check"
ORDER = 5
def evaluate(context: RuleContext):
if not context.contributions_error:
return []
return [
task(
rule_id=RULE_ID,
member=context.member,
key_suffix="invalid-file",
severity="error",
code="invalid_contributions_file",
title=f"{context.member.display_name}: Beitragsdatei beschädigt",
detail=(f"{context.contributions_error}. Die Datei wird nicht automatisch überschrieben."),
)
]
+44 -7
View File
@@ -10,7 +10,7 @@ from pathlib import Path
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
from ccma.domain.models import ContributionData, HousekeeperFinding from ccma.domain.models import HousekeeperFinding
from ccma.rules.api import RuleAction, RuleContext from ccma.rules.api import RuleAction, RuleContext
from ccma.rules.loader import LoadedRule, load_rules from ccma.rules.loader import LoadedRule, load_rules
from ccma.services.intervals import AnniversaryInterval, parse_anniversary_intervals from ccma.services.intervals import AnniversaryInterval, parse_anniversary_intervals
@@ -67,13 +67,14 @@ class Housekeeper:
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 member in self.repository.list_members(): for member_id in self.repository.list_member_ids():
try: try:
contributions = self.repository.get_contributions(member.member_id) member, contributions = self.repository.preflight_member_record(member_id)
contributions_error = None
except RepositoryError as exc: except RepositoryError as exc:
contributions = ContributionData() self._refresh_record_integrity_task(items, member_id, str(exc), run_id, now)
contributions_error = str(exc) successful_scopes.add(("member-record-check", member_id))
continue
successful_scopes.add(("member-record-check", 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:
@@ -83,7 +84,6 @@ class Housekeeper:
today=current_date, today=current_date,
settings=self.settings, settings=self.settings,
repository_config=repository_config, repository_config=repository_config,
contributions_error=contributions_error,
) )
actions = rule.evaluate(context) actions = rule.evaluate(context)
if not isinstance(actions, list): if not isinstance(actions, list):
@@ -126,6 +126,43 @@ class Housekeeper:
write_json_atomic(self.state_path, working) write_json_atomic(self.state_path, working)
return _open_findings(working["items"]) return _open_findings(working["items"])
@staticmethod
def _refresh_record_integrity_task(
items: dict[str, dict[str, Any]],
member_id: str,
detail: str,
run_id: str,
now: str,
) -> None:
key = f"member-record-check:{member_id}:invalid-record"
item = items.get(key, {})
was_resolved = item.get("status") == "resolved"
item.update(
{
"key": key,
"rule_id": "member-record-check",
"rule_file": "<preflight>",
"rule_source": "housekeeper",
"member_id": member_id,
"action": "task",
"status": "open",
"severity": "error",
"code": "invalid_member_record",
"title": f"Mitgliederakte {member_id}: Daten beschädigt",
"detail": f"{detail}. Für diese Akte wurden keine Regeln ausgeführt.",
"due_date": None,
"first_seen_run": item.get("first_seen_run", run_id),
"first_seen_at": item.get("first_seen_at", now),
"last_seen_run": run_id,
"last_seen_at": now,
"seen_count": int(item.get("seen_count", 0)) + 1,
"reopened_count": int(item.get("reopened_count", 0)) + (1 if was_resolved else 0),
"resolved_run": None,
"resolved_at": None,
}
)
items[key] = item
def _apply_action( def _apply_action(
self, self,
items: dict[str, dict[str, Any]], items: dict[str, dict[str, Any]],
+42 -8
View File
@@ -77,13 +77,12 @@ class MemberRepository:
seen_numbers: dict[str, str] = {} seen_numbers: dict[str, str] = {}
for member_dir in self._member_directories(): for member_dir in self._member_directories():
try: try:
member = self.get_member(member_dir.name) member, _contributions = self.preflight_member_record(member_dir.name)
validate_member_dates( validate_member_dates(
birth_date=member.birth_date, birth_date=member.birth_date,
accepted_at=member.accepted_at, accepted_at=member.accepted_at,
membership_started_at=member.membership_started_at, membership_started_at=member.membership_started_at,
) )
self.get_contributions(member.member_id)
if member.member_id != member_dir.name: if member.member_id != member_dir.name:
errors.append(f"{member_dir.name}/member.json: member_id stimmt nicht mit Ordner überein") errors.append(f"{member_dir.name}/member.json: member_id stimmt nicht mit Ordner überein")
normalized_number = member.member_number.casefold().strip() normalized_number = member.member_number.casefold().strip()
@@ -108,18 +107,45 @@ class MemberRepository:
def list_members(self) -> list[Member]: def list_members(self) -> list[Member]:
members: list[Member] = [] members: list[Member] = []
for directory in self._member_directories(): for member_id in self.list_member_ids():
try: try:
members.append(self.get_member(directory.name)) members.append(self.get_member(member_id))
except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError): except RepositoryError:
continue continue
return sorted(members, key=lambda item: (item.last_name.casefold(), item.first_name.casefold())) return sorted(members, key=lambda item: (item.last_name.casefold(), item.first_name.casefold()))
def list_member_ids(self) -> list[str]:
return sorted(directory.name for directory in self._member_directories())
def get_member(self, member_id: str) -> Member: def get_member(self, member_id: str) -> Member:
path = self._member_path(member_id) / "member.json" path = self._member_path(member_id) / "member.json"
if not path.is_file(): if not path.is_file():
raise RepositoryError(f"Mitglied nicht gefunden: {member_id}") raise RepositoryError(f"Mitglied nicht gefunden: {member_id}")
return Member.from_dict(read_json(path)) try:
raw = read_json(path)
if not isinstance(raw, dict):
raise TypeError("Wurzelelement muss ein JSON-Objekt sein")
for section_name in ("person", "membership", "contribution_profile"):
if section_name in raw and not isinstance(raw[section_name], dict):
raise TypeError(f"{section_name} muss ein JSON-Objekt sein")
return Member.from_dict(raw)
except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError) as exc:
raise RepositoryError(f"{member_id}/member.json konnte nicht gelesen werden: {exc}") from exc
def preflight_member_record(self, member_id: str) -> tuple[Member, ContributionData]:
member = self.get_member(member_id)
if member.member_id != member_id:
raise RepositoryError(f"{member_id}/member.json: member_id stimmt nicht mit Ordner überein")
if member.schema_version != 1:
raise RepositoryError(
f"{member_id}/member.json: nicht unterstützte schema_version {member.schema_version}"
)
contributions = self.get_contributions(member_id)
try:
self.get_events(member_id)
except (OSError, UnicodeError) as exc:
raise RepositoryError(f"{member_id}/events.jsonl konnte nicht gelesen werden: {exc}") from exc
return member, contributions
def create_member( def create_member(
self, self,
@@ -203,6 +229,11 @@ class MemberRepository:
raw = read_json(path) raw = read_json(path)
if not isinstance(raw, dict): if not isinstance(raw, dict):
raise TypeError("Wurzelelement muss ein JSON-Objekt sein") raise TypeError("Wurzelelement muss ein JSON-Objekt sein")
for field_name in ("claims", "payments", "allocations"):
if field_name in raw and not isinstance(raw[field_name], list):
raise TypeError(f"{field_name} muss eine JSON-Liste sein")
if field_name in raw and any(not isinstance(item, dict) for item in raw[field_name]):
raise TypeError(f"Alle Einträge in {field_name} müssen JSON-Objekte sein")
return ContributionData.from_dict(raw) return ContributionData.from_dict(raw)
except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc: except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
raise RepositoryError( raise RepositoryError(
@@ -255,8 +286,11 @@ class MemberRepository:
if not line.strip(): if not line.strip():
continue continue
try: try:
events.append(Event.from_dict(json.loads(line))) raw = json.loads(line)
except (ValueError, TypeError, KeyError, json.JSONDecodeError) as exc: if not isinstance(raw, dict):
raise TypeError("Event muss ein JSON-Objekt sein")
events.append(Event.from_dict(raw))
except (AttributeError, ValueError, TypeError, KeyError, json.JSONDecodeError) as exc:
raise RepositoryError(f"Ungültiges Event in Zeile {line_number}: {exc}") from exc raise RepositoryError(f"Ungültiges Event in Zeile {line_number}: {exc}") from exc
return events return events
+6 -1
View File
@@ -7,7 +7,7 @@ from ccma import __version__
from ccma.config import AppConfig from ccma.config import AppConfig
from ccma.domain.models import HousekeeperFinding, Member from ccma.domain.models import HousekeeperFinding, Member
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
from ccma.ui.dialogs import NewMemberDialog from ccma.ui.dialogs import NewMemberDialog
from ccma.ui.icons import IconStore from ccma.ui.icons import IconStore
from ccma.ui.member_tab import MemberTab from ccma.ui.member_tab import MemberTab
@@ -264,7 +264,12 @@ class MainWindow(ttk.Frame):
key = f"member:{member_id}" key = f"member:{member_id}"
if self.tabs.focus(key): if self.tabs.focus(key):
return return
try:
member = self.repository.get_member(member_id) member = self.repository.get_member(member_id)
except RepositoryError as exc:
self.status_var.set(str(exc))
messagebox.showerror("Mitgliederakte beschädigt", str(exc), parent=self)
return
tab = MemberTab( tab = MemberTab(
self.notebook, self.notebook,
self.repository, self.repository,
+13
View File
@@ -108,6 +108,19 @@ def test_repository_reports_empty_contributions_file(tmp_path) -> None:
assert any("contributions.json" in error for error in repository.validate()) assert any("contributions.json" in error for error in repository.validate())
def test_repository_rejects_structurally_invalid_member_json(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Invalid", last_name="Structure")
path = repository.members_root / member.member_id / "member.json"
raw = json.loads(path.read_text(encoding="utf-8"))
raw["person"] = []
path.write_text(json.dumps(raw), encoding="utf-8")
with pytest.raises(RepositoryError, match="person muss ein JSON-Objekt sein"):
repository.preflight_member_record(member.member_id)
def test_member_path_rejects_traversal(tmp_path) -> None: def test_member_path_rejects_traversal(tmp_path) -> None:
repository = MemberRepository(tmp_path) repository = MemberRepository(tmp_path)
repository.initialize() repository.initialize()
+56 -2
View File
@@ -122,7 +122,61 @@ def test_broken_contributions_file_creates_task_without_overwriting_file(tmp_pat
findings = Housekeeper(repository).run(today=date(2026, 6, 21)) findings = Housekeeper(repository).run(today=date(2026, 6, 21))
assert path.read_bytes() == b"" assert path.read_bytes() == b""
assert [finding.code for finding in findings] == ["invalid_contributions_file"] assert [finding.code for finding in findings] == ["invalid_member_record"]
state = json.loads((repository.root / "hausmeister.json").read_text(encoding="utf-8")) state = json.loads((repository.root / "hausmeister.json").read_text(encoding="utf-8"))
task = next(item for item in state["items"] if item["code"] == "invalid_contributions_file") task = next(item for item in state["items"] if item["code"] == "invalid_member_record")
assert task["status"] == "open" assert task["status"] == "open"
def test_preflight_skips_all_rules_for_broken_member_file(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Broken", last_name="Member")
member_path = repository.members_root / member.member_id / "member.json"
contributions_path = repository.members_root / member.member_id / "contributions.json"
contributions_before = contributions_path.read_bytes()
member_path.write_text("{", encoding="utf-8")
findings = Housekeeper(repository).run(today=date(2026, 6, 21))
state = json.loads((repository.root / "hausmeister.json").read_text(encoding="utf-8"))
assert [finding.code for finding in findings] == ["invalid_member_record"]
assert len(state["items"]) == 1
assert contributions_path.read_bytes() == contributions_before
def test_preflight_blocks_rules_for_broken_event_log(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Broken", last_name="Events", birth_date="1990-01-01")
member.status = "active"
member.accepted_at = "2026-01-01"
member.membership_started_at = "2026-01-01"
repository.save_member(member)
events_path = repository.members_root / member.member_id / "events.jsonl"
with events_path.open("a", encoding="utf-8") as handle:
handle.write("not-json\n")
findings = Housekeeper(repository).run(today=date(2026, 6, 21))
assert [finding.code for finding in findings] == ["invalid_member_record"]
assert repository.get_contributions(member.member_id).claims == []
def test_preflight_task_resolves_after_record_is_repaired(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Repair", last_name="Record", birth_date="1990-01-01")
contributions_path = repository.members_root / member.member_id / "contributions.json"
original = contributions_path.read_bytes()
contributions_path.write_bytes(b"")
housekeeper = Housekeeper(repository)
housekeeper.run(today=date(2026, 6, 21))
contributions_path.write_bytes(original)
housekeeper.run(today=date(2026, 6, 21))
state = json.loads((repository.root / "hausmeister.json").read_text(encoding="utf-8"))
task = next(item for item in state["items"] if item["code"] == "invalid_member_record")
assert task["status"] == "resolved"
assert task["resolved_run"] == "2026-06-21:000002"