fix: preflight member records before rules

This commit is contained in:
Marcel Peterkau
2026-06-21 17:54:09 +02:00
parent 55bc3b666e
commit 7596e47981
10 changed files with 163 additions and 44 deletions
+1
View File
@@ -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.",
-1
View File
@@ -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)
-2
View File
@@ -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"}:
@@ -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 []
@@ -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."),
)
]
+44 -7
View File
@@ -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": "<preflight>",
"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]],
+42 -8
View File
@@ -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
+7 -2
View File
@@ -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,