fix: tolerate damaged contribution files

This commit is contained in:
Marcel Peterkau
2026-06-21 17:50:56 +02:00
parent 4bc1a8a200
commit 55bc3b666e
10 changed files with 87 additions and 4 deletions
+1
View File
@@ -19,6 +19,7 @@
"Mitgliederlisten bleiben bei fehlerhaften Datumswerten bedienbar; der Hausmeister meldet die betroffene Akte zur Korrektur.", "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.", "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.",
"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,6 +15,7 @@ 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,6 +7,8 @@ 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,6 +12,8 @@ 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 []
@@ -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."),
)
]
+11 -2
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 HousekeeperFinding from ccma.domain.models import ContributionData, 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
@@ -68,21 +68,30 @@ 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 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: for rule in rules:
scope = (rule.rule_id, member.member_id) scope = (rule.rule_id, member.member_id)
try: try:
context = RuleContext( context = RuleContext(
member=member, member=member,
contributions=self.repository.get_contributions(member.member_id), contributions=contributions,
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):
raise TypeError("evaluate(context) muss eine Liste zurückgeben") raise TypeError("evaluate(context) muss eine Liste zurückgeben")
for action in actions: for action in actions:
self._apply_action(items, action, rule, run_id, now) 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: except Exception as exc:
self._refresh_task( self._refresh_task(
items, items,
+12 -1
View File
@@ -83,6 +83,7 @@ class MemberRepository:
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()
@@ -92,6 +93,8 @@ class MemberRepository:
) )
elif normalized_number: elif normalized_number:
seen_numbers[normalized_number] = member.member_id seen_numbers[normalized_number] = member.member_id
except RepositoryError as exc:
errors.append(str(exc))
except ( except (
OSError, OSError,
ValueError, ValueError,
@@ -196,7 +199,15 @@ class MemberRepository:
path = self._member_path(member_id) / "contributions.json" path = self._member_path(member_id) / "contributions.json"
if not path.exists(): if not path.exists():
return ContributionData() 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: def save_contributions(self, member_id: str, data: ContributionData) -> None:
self.get_member(member_id) self.get_member(member_id)
+5 -1
View File
@@ -207,8 +207,12 @@ class MemberTab(ttk.Frame):
self.timeline.insert("", "end", values=(_format_timestamp(event), _event_label(event))) self.timeline.insert("", "end", values=(_format_timestamp(event), _event_label(event)))
def _refresh_contributions(self) -> None: def _refresh_contributions(self) -> None:
data = self.repository.get_contributions(self.member_id)
self.claims.delete(*self.claims.get_children()) 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: for claim in data.claims:
self.claims.insert( self.claims.insert(
"", "",
+13
View File
@@ -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") 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: def test_member_path_rejects_traversal(tmp_path) -> None:
repository = MemberRepository(tmp_path) repository = MemberRepository(tmp_path)
repository.initialize() repository.initialize()
+20
View File
@@ -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 (repository.root / "hausmeister.json").read_bytes() == state_before
assert not (repository.root / ".hausmeister.lock").exists() 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"