mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-07-01 03:04:52 +02:00
fix: tolerate damaged contribution files
This commit is contained in:
@@ -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.",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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."),
|
||||||
|
)
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user