From 7596e479819a197d2393e430d335ac70ba19d483 Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Sun, 21 Jun 2026 17:54:09 +0200 Subject: [PATCH] fix: preflight member records before rules --- src/ccma/assets/CHANGELOG.json | 1 + src/ccma/rules/api.py | 1 - src/ccma/rules/scripts/claim_status.py | 2 - src/ccma/rules/scripts/contribution_claims.py | 2 - src/ccma/rules/scripts/contributions_check.py | 20 ------- src/ccma/services/housekeeper.py | 51 +++++++++++++--- src/ccma/storage/repository.py | 50 +++++++++++++--- src/ccma/ui/main_window.py | 9 ++- tests/test_repository.py | 13 +++++ tests/test_rules.py | 58 ++++++++++++++++++- 10 files changed, 163 insertions(+), 44 deletions(-) delete mode 100644 src/ccma/rules/scripts/contributions_check.py diff --git a/src/ccma/assets/CHANGELOG.json b/src/ccma/assets/CHANGELOG.json index 9798403..5c99e61 100644 --- a/src/ccma/assets/CHANGELOG.json +++ b/src/ccma/assets/CHANGELOG.json @@ -20,6 +20,7 @@ "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.", "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.", "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.", diff --git a/src/ccma/rules/api.py b/src/ccma/rules/api.py index f110b70..e008ade 100644 --- a/src/ccma/rules/api.py +++ b/src/ccma/rules/api.py @@ -15,7 +15,6 @@ class RuleContext: today: date settings: Any repository_config: Mapping[str, Any] - contributions_error: str | None = None @dataclass(frozen=True, slots=True) diff --git a/src/ccma/rules/scripts/claim_status.py b/src/ccma/rules/scripts/claim_status.py index 4c4ada4..346b474 100644 --- a/src/ccma/rules/scripts/claim_status.py +++ b/src/ccma/rules/scripts/claim_status.py @@ -7,8 +7,6 @@ ORDER = 50 def evaluate(context: RuleContext): - if context.contributions_error: - return [] actions = [] for claim in context.contributions.claims: if str(claim.get("status", "open")) not in {"open", "partially_paid"}: diff --git a/src/ccma/rules/scripts/contribution_claims.py b/src/ccma/rules/scripts/contribution_claims.py index c1f8e3d..bc5b9c3 100644 --- a/src/ccma/rules/scripts/contribution_claims.py +++ b/src/ccma/rules/scripts/contribution_claims.py @@ -12,8 +12,6 @@ CENT = Decimal("0.01") def evaluate(context: RuleContext): - if context.contributions_error: - return [] member = context.member if member.honorary or member.status not in CONTRIBUTION_STATUSES: return [] diff --git a/src/ccma/rules/scripts/contributions_check.py b/src/ccma/rules/scripts/contributions_check.py deleted file mode 100644 index eac9f23..0000000 --- a/src/ccma/rules/scripts/contributions_check.py +++ /dev/null @@ -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."), - ) - ] diff --git a/src/ccma/services/housekeeper.py b/src/ccma/services/housekeeper.py index f1fa6e6..210cd37 100644 --- a/src/ccma/services/housekeeper.py +++ b/src/ccma/services/housekeeper.py @@ -10,7 +10,7 @@ from pathlib import Path from typing import Any 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.loader import LoadedRule, load_rules from ccma.services.intervals import AnniversaryInterval, parse_anniversary_intervals @@ -67,13 +67,14 @@ class Housekeeper: rules = load_rules(self.repository.root) repository_config = self.repository.get_configuration() - for member in self.repository.list_members(): + for member_id in self.repository.list_member_ids(): try: - contributions = self.repository.get_contributions(member.member_id) - contributions_error = None + member, contributions = self.repository.preflight_member_record(member_id) except RepositoryError as exc: - contributions = ContributionData() - contributions_error = str(exc) + self._refresh_record_integrity_task(items, member_id, str(exc), run_id, now) + successful_scopes.add(("member-record-check", member_id)) + continue + successful_scopes.add(("member-record-check", member_id)) for rule in rules: scope = (rule.rule_id, member.member_id) try: @@ -83,7 +84,6 @@ class Housekeeper: today=current_date, settings=self.settings, repository_config=repository_config, - contributions_error=contributions_error, ) actions = rule.evaluate(context) if not isinstance(actions, list): @@ -126,6 +126,43 @@ class Housekeeper: write_json_atomic(self.state_path, working) 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": "", + "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( self, items: dict[str, dict[str, Any]], diff --git a/src/ccma/storage/repository.py b/src/ccma/storage/repository.py index 1befb16..2e433a0 100644 --- a/src/ccma/storage/repository.py +++ b/src/ccma/storage/repository.py @@ -77,13 +77,12 @@ class MemberRepository: seen_numbers: dict[str, str] = {} for member_dir in self._member_directories(): try: - member = self.get_member(member_dir.name) + member, _contributions = self.preflight_member_record(member_dir.name) validate_member_dates( birth_date=member.birth_date, accepted_at=member.accepted_at, membership_started_at=member.membership_started_at, ) - self.get_contributions(member.member_id) if member.member_id != member_dir.name: errors.append(f"{member_dir.name}/member.json: member_id stimmt nicht mit Ordner überein") normalized_number = member.member_number.casefold().strip() @@ -108,18 +107,45 @@ class MemberRepository: def list_members(self) -> list[Member]: members: list[Member] = [] - for directory in self._member_directories(): + for member_id in self.list_member_ids(): try: - members.append(self.get_member(directory.name)) - except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError): + members.append(self.get_member(member_id)) + except RepositoryError: continue 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: path = self._member_path(member_id) / "member.json" if not path.is_file(): 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( self, @@ -203,6 +229,11 @@ class MemberRepository: raw = read_json(path) if not isinstance(raw, dict): 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) except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc: raise RepositoryError( @@ -255,8 +286,11 @@ class MemberRepository: if not line.strip(): continue try: - events.append(Event.from_dict(json.loads(line))) - except (ValueError, TypeError, KeyError, json.JSONDecodeError) as exc: + raw = json.loads(line) + 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 return events diff --git a/src/ccma/ui/main_window.py b/src/ccma/ui/main_window.py index e93ce18..c9427e5 100644 --- a/src/ccma/ui/main_window.py +++ b/src/ccma/ui/main_window.py @@ -7,7 +7,7 @@ from ccma import __version__ from ccma.config import AppConfig from ccma.domain.models import HousekeeperFinding, Member 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.icons import IconStore from ccma.ui.member_tab import MemberTab @@ -264,7 +264,12 @@ class MainWindow(ttk.Frame): key = f"member:{member_id}" if self.tabs.focus(key): return - member = self.repository.get_member(member_id) + try: + 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( self.notebook, self.repository, diff --git a/tests/test_repository.py b/tests/test_repository.py index 4670026..3e15236 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -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()) +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: repository = MemberRepository(tmp_path) repository.initialize() diff --git a/tests/test_rules.py b/tests/test_rules.py index d7ee6e5..d37f1c9 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -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)) 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")) - 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" + + +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"