mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-07-01 03:04:52 +02:00
fix: preflight member records before rules
This commit is contained in:
@@ -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.",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."),
|
||||
)
|
||||
]
|
||||
@@ -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]],
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -108,6 +108,19 @@ def test_repository_reports_empty_contributions_file(tmp_path) -> None:
|
||||
assert any("contributions.json" in error for error in repository.validate())
|
||||
|
||||
|
||||
def test_repository_rejects_structurally_invalid_member_json(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member = repository.create_member(first_name="Invalid", last_name="Structure")
|
||||
path = repository.members_root / member.member_id / "member.json"
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
raw["person"] = []
|
||||
path.write_text(json.dumps(raw), encoding="utf-8")
|
||||
|
||||
with pytest.raises(RepositoryError, match="person muss ein JSON-Objekt sein"):
|
||||
repository.preflight_member_record(member.member_id)
|
||||
|
||||
|
||||
def test_member_path_rejects_traversal(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
|
||||
+56
-2
@@ -122,7 +122,61 @@ def test_broken_contributions_file_creates_task_without_overwriting_file(tmp_pat
|
||||
findings = Housekeeper(repository).run(today=date(2026, 6, 21))
|
||||
|
||||
assert path.read_bytes() == b""
|
||||
assert [finding.code for finding in findings] == ["invalid_contributions_file"]
|
||||
assert [finding.code for finding in findings] == ["invalid_member_record"]
|
||||
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")
|
||||
task = next(item for item in state["items"] if item["code"] == "invalid_member_record")
|
||||
assert task["status"] == "open"
|
||||
|
||||
|
||||
def test_preflight_skips_all_rules_for_broken_member_file(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member = repository.create_member(first_name="Broken", last_name="Member")
|
||||
member_path = repository.members_root / member.member_id / "member.json"
|
||||
contributions_path = repository.members_root / member.member_id / "contributions.json"
|
||||
contributions_before = contributions_path.read_bytes()
|
||||
member_path.write_text("{", encoding="utf-8")
|
||||
|
||||
findings = Housekeeper(repository).run(today=date(2026, 6, 21))
|
||||
state = json.loads((repository.root / "hausmeister.json").read_text(encoding="utf-8"))
|
||||
|
||||
assert [finding.code for finding in findings] == ["invalid_member_record"]
|
||||
assert len(state["items"]) == 1
|
||||
assert contributions_path.read_bytes() == contributions_before
|
||||
|
||||
|
||||
def test_preflight_blocks_rules_for_broken_event_log(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member = repository.create_member(first_name="Broken", last_name="Events", 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)
|
||||
events_path = repository.members_root / member.member_id / "events.jsonl"
|
||||
with events_path.open("a", encoding="utf-8") as handle:
|
||||
handle.write("not-json\n")
|
||||
|
||||
findings = Housekeeper(repository).run(today=date(2026, 6, 21))
|
||||
|
||||
assert [finding.code for finding in findings] == ["invalid_member_record"]
|
||||
assert repository.get_contributions(member.member_id).claims == []
|
||||
|
||||
|
||||
def test_preflight_task_resolves_after_record_is_repaired(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member = repository.create_member(first_name="Repair", last_name="Record", birth_date="1990-01-01")
|
||||
contributions_path = repository.members_root / member.member_id / "contributions.json"
|
||||
original = contributions_path.read_bytes()
|
||||
contributions_path.write_bytes(b"")
|
||||
housekeeper = Housekeeper(repository)
|
||||
housekeeper.run(today=date(2026, 6, 21))
|
||||
contributions_path.write_bytes(original)
|
||||
|
||||
housekeeper.run(today=date(2026, 6, 21))
|
||||
state = json.loads((repository.root / "hausmeister.json").read_text(encoding="utf-8"))
|
||||
task = next(item for item in state["items"] if item["code"] == "invalid_member_record")
|
||||
|
||||
assert task["status"] == "resolved"
|
||||
assert task["resolved_run"] == "2026-06-21:000002"
|
||||
|
||||
Reference in New Issue
Block a user