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.",
|
"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.",
|
"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.",
|
"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,7 +15,6 @@ 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,8 +7,6 @@ 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,8 +12,6 @@ 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 []
|
||||||
|
|||||||
@@ -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 typing import Any
|
||||||
from uuid import uuid4
|
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.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
|
||||||
@@ -67,13 +67,14 @@ 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_id in self.repository.list_member_ids():
|
||||||
try:
|
try:
|
||||||
contributions = self.repository.get_contributions(member.member_id)
|
member, contributions = self.repository.preflight_member_record(member_id)
|
||||||
contributions_error = None
|
|
||||||
except RepositoryError as exc:
|
except RepositoryError as exc:
|
||||||
contributions = ContributionData()
|
self._refresh_record_integrity_task(items, member_id, str(exc), run_id, now)
|
||||||
contributions_error = str(exc)
|
successful_scopes.add(("member-record-check", member_id))
|
||||||
|
continue
|
||||||
|
successful_scopes.add(("member-record-check", member_id))
|
||||||
for rule in rules:
|
for rule in rules:
|
||||||
scope = (rule.rule_id, member.member_id)
|
scope = (rule.rule_id, member.member_id)
|
||||||
try:
|
try:
|
||||||
@@ -83,7 +84,6 @@ class Housekeeper:
|
|||||||
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):
|
||||||
@@ -126,6 +126,43 @@ class Housekeeper:
|
|||||||
write_json_atomic(self.state_path, working)
|
write_json_atomic(self.state_path, working)
|
||||||
return _open_findings(working["items"])
|
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(
|
def _apply_action(
|
||||||
self,
|
self,
|
||||||
items: dict[str, dict[str, Any]],
|
items: dict[str, dict[str, Any]],
|
||||||
|
|||||||
@@ -77,13 +77,12 @@ class MemberRepository:
|
|||||||
seen_numbers: dict[str, str] = {}
|
seen_numbers: dict[str, str] = {}
|
||||||
for member_dir in self._member_directories():
|
for member_dir in self._member_directories():
|
||||||
try:
|
try:
|
||||||
member = self.get_member(member_dir.name)
|
member, _contributions = self.preflight_member_record(member_dir.name)
|
||||||
validate_member_dates(
|
validate_member_dates(
|
||||||
birth_date=member.birth_date,
|
birth_date=member.birth_date,
|
||||||
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()
|
||||||
@@ -108,18 +107,45 @@ class MemberRepository:
|
|||||||
|
|
||||||
def list_members(self) -> list[Member]:
|
def list_members(self) -> list[Member]:
|
||||||
members: list[Member] = []
|
members: list[Member] = []
|
||||||
for directory in self._member_directories():
|
for member_id in self.list_member_ids():
|
||||||
try:
|
try:
|
||||||
members.append(self.get_member(directory.name))
|
members.append(self.get_member(member_id))
|
||||||
except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError):
|
except RepositoryError:
|
||||||
continue
|
continue
|
||||||
return sorted(members, key=lambda item: (item.last_name.casefold(), item.first_name.casefold()))
|
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:
|
def get_member(self, member_id: str) -> Member:
|
||||||
path = self._member_path(member_id) / "member.json"
|
path = self._member_path(member_id) / "member.json"
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
raise RepositoryError(f"Mitglied nicht gefunden: {member_id}")
|
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(
|
def create_member(
|
||||||
self,
|
self,
|
||||||
@@ -203,6 +229,11 @@ class MemberRepository:
|
|||||||
raw = read_json(path)
|
raw = read_json(path)
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
raise TypeError("Wurzelelement muss ein JSON-Objekt sein")
|
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)
|
return ContributionData.from_dict(raw)
|
||||||
except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
|
except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
|
||||||
raise RepositoryError(
|
raise RepositoryError(
|
||||||
@@ -255,8 +286,11 @@ class MemberRepository:
|
|||||||
if not line.strip():
|
if not line.strip():
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
events.append(Event.from_dict(json.loads(line)))
|
raw = json.loads(line)
|
||||||
except (ValueError, TypeError, KeyError, json.JSONDecodeError) as exc:
|
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
|
raise RepositoryError(f"Ungültiges Event in Zeile {line_number}: {exc}") from exc
|
||||||
return events
|
return events
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from ccma import __version__
|
|||||||
from ccma.config import AppConfig
|
from ccma.config import AppConfig
|
||||||
from ccma.domain.models import HousekeeperFinding, Member
|
from ccma.domain.models import HousekeeperFinding, Member
|
||||||
from ccma.services.housekeeper import Housekeeper
|
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.dialogs import NewMemberDialog
|
||||||
from ccma.ui.icons import IconStore
|
from ccma.ui.icons import IconStore
|
||||||
from ccma.ui.member_tab import MemberTab
|
from ccma.ui.member_tab import MemberTab
|
||||||
@@ -264,7 +264,12 @@ class MainWindow(ttk.Frame):
|
|||||||
key = f"member:{member_id}"
|
key = f"member:{member_id}"
|
||||||
if self.tabs.focus(key):
|
if self.tabs.focus(key):
|
||||||
return
|
return
|
||||||
|
try:
|
||||||
member = self.repository.get_member(member_id)
|
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(
|
tab = MemberTab(
|
||||||
self.notebook,
|
self.notebook,
|
||||||
self.repository,
|
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())
|
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:
|
def test_member_path_rejects_traversal(tmp_path) -> None:
|
||||||
repository = MemberRepository(tmp_path)
|
repository = MemberRepository(tmp_path)
|
||||||
repository.initialize()
|
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))
|
findings = Housekeeper(repository).run(today=date(2026, 6, 21))
|
||||||
|
|
||||||
assert path.read_bytes() == b""
|
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"))
|
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"
|
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