From 55bc3b666e131310fe59190bcad41204e3608593 Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Sun, 21 Jun 2026 17:50:56 +0200 Subject: [PATCH] fix: tolerate damaged contribution files --- 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 | 13 ++++++++++-- src/ccma/storage/repository.py | 13 +++++++++++- src/ccma/ui/member_tab.py | 6 +++++- tests/test_repository.py | 13 ++++++++++++ tests/test_rules.py | 20 +++++++++++++++++++ 10 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 src/ccma/rules/scripts/contributions_check.py diff --git a/src/ccma/assets/CHANGELOG.json b/src/ccma/assets/CHANGELOG.json index f2b8bb5..9798403 100644 --- a/src/ccma/assets/CHANGELOG.json +++ b/src/ccma/assets/CHANGELOG.json @@ -19,6 +19,7 @@ "Mitgliederlisten bleiben bei fehlerhaften Datumswerten bedienbar; der Hausmeister meldet die betroffene Akte zur Korrektur.", "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.", "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 e008ade..f110b70 100644 --- a/src/ccma/rules/api.py +++ b/src/ccma/rules/api.py @@ -15,6 +15,7 @@ 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 346b474..4c4ada4 100644 --- a/src/ccma/rules/scripts/claim_status.py +++ b/src/ccma/rules/scripts/claim_status.py @@ -7,6 +7,8 @@ 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 bc5b9c3..c1f8e3d 100644 --- a/src/ccma/rules/scripts/contribution_claims.py +++ b/src/ccma/rules/scripts/contribution_claims.py @@ -12,6 +12,8 @@ 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 new file mode 100644 index 0000000..eac9f23 --- /dev/null +++ b/src/ccma/rules/scripts/contributions_check.py @@ -0,0 +1,20 @@ +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 f03b589..f1fa6e6 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 HousekeeperFinding +from ccma.domain.models import ContributionData, 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 @@ -68,21 +68,30 @@ class Housekeeper: rules = load_rules(self.repository.root) repository_config = self.repository.get_configuration() for member in self.repository.list_members(): + try: + contributions = self.repository.get_contributions(member.member_id) + contributions_error = None + except RepositoryError as exc: + contributions = ContributionData() + contributions_error = str(exc) for rule in rules: scope = (rule.rule_id, member.member_id) try: context = RuleContext( member=member, - contributions=self.repository.get_contributions(member.member_id), + contributions=contributions, today=current_date, settings=self.settings, repository_config=repository_config, + contributions_error=contributions_error, ) actions = rule.evaluate(context) if not isinstance(actions, list): raise TypeError("evaluate(context) muss eine Liste zurückgeben") for action in actions: self._apply_action(items, action, rule, run_id, now) + if action.action == "create_claim": + contributions = self.repository.get_contributions(member.member_id) except Exception as exc: self._refresh_task( items, diff --git a/src/ccma/storage/repository.py b/src/ccma/storage/repository.py index df2d92d..1befb16 100644 --- a/src/ccma/storage/repository.py +++ b/src/ccma/storage/repository.py @@ -83,6 +83,7 @@ class MemberRepository: 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() @@ -92,6 +93,8 @@ class MemberRepository: ) elif normalized_number: seen_numbers[normalized_number] = member.member_id + except RepositoryError as exc: + errors.append(str(exc)) except ( OSError, ValueError, @@ -196,7 +199,15 @@ class MemberRepository: path = self._member_path(member_id) / "contributions.json" if not path.exists(): return ContributionData() - return ContributionData.from_dict(read_json(path)) + try: + raw = read_json(path) + if not isinstance(raw, dict): + raise TypeError("Wurzelelement muss ein JSON-Objekt sein") + return ContributionData.from_dict(raw) + except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc: + raise RepositoryError( + f"{member_id}/contributions.json konnte nicht gelesen werden: {exc}" + ) from exc def save_contributions(self, member_id: str, data: ContributionData) -> None: self.get_member(member_id) diff --git a/src/ccma/ui/member_tab.py b/src/ccma/ui/member_tab.py index 3fced02..a618e17 100644 --- a/src/ccma/ui/member_tab.py +++ b/src/ccma/ui/member_tab.py @@ -207,8 +207,12 @@ class MemberTab(ttk.Frame): self.timeline.insert("", "end", values=(_format_timestamp(event), _event_label(event))) def _refresh_contributions(self) -> None: - data = self.repository.get_contributions(self.member_id) self.claims.delete(*self.claims.get_children()) + try: + data = self.repository.get_contributions(self.member_id) + except RepositoryError as exc: + self.contribution_summary.set(f"FEHLER: {exc}") + return for claim in data.claims: self.claims.insert( "", diff --git a/tests/test_repository.py b/tests/test_repository.py index 4f4a94a..4670026 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -95,6 +95,19 @@ def test_repository_accepts_local_date_input_and_rejects_invalid_dates(tmp_path) repository.create_member(first_name="Invalid", last_name="Date", birth_date="31.02.2000") +def test_repository_reports_empty_contributions_file(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Empty", last_name="Contributions") + path = repository.members_root / member.member_id / "contributions.json" + path.write_text("", encoding="utf-8") + + with pytest.raises(RepositoryError, match="contributions.json konnte nicht gelesen"): + repository.get_contributions(member.member_id) + + assert any("contributions.json" in error for error in repository.validate()) + + 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 21f5b3a..d7ee6e5 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -106,3 +106,23 @@ def test_failed_run_does_not_advance_persisted_run_id(tmp_path) -> None: assert (repository.root / "hausmeister.json").read_bytes() == state_before assert not (repository.root / ".hausmeister.lock").exists() + + +def test_broken_contributions_file_creates_task_without_overwriting_file(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Broken", last_name="Contributions", 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) + path = repository.members_root / member.member_id / "contributions.json" + path.write_bytes(b"") + + findings = Housekeeper(repository).run(today=date(2026, 6, 21)) + + assert path.read_bytes() == b"" + assert [finding.code for finding in findings] == ["invalid_contributions_file"] + 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") + assert task["status"] == "open"