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.",
"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.",
+1
View File
@@ -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)
+2
View File
@@ -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"}:
@@ -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 []
@@ -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 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,
+12 -1
View File
@@ -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)
+5 -1
View File
@@ -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(
"",