diff --git a/README.md b/README.md index 8bf1b4c..ab6e84e 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ On first start, select or create the central member-store directory. The ```text member-store/ ├── repository.json -├── hausmeister.json +├── housekeeper.json ├── rules/ └── members/ └── / @@ -57,7 +57,7 @@ Store rules are trusted executable Python code. Only place reviewed rules from trusted sources in this directory. Rules return structured `RuleAction` objects; CCMA performs all file writes, duplicate checks, audit events, and atomic updates. -`hausmeister.json` is written only after a complete run. Each refreshed task gets +`housekeeper.json` is written only after a complete run. Each refreshed task gets the pending run ID. A failed run therefore cannot advance the stored counter or silently resolve existing tasks. diff --git a/src/ccma/assets/CHANGELOG.json b/src/ccma/assets/CHANGELOG.json index a61485e..98f99ce 100644 --- a/src/ccma/assets/CHANGELOG.json +++ b/src/ccma/assets/CHANGELOG.json @@ -24,6 +24,7 @@ "Der Hausmeister-Tab zeigt den vollständigen Inhalt eines markierten Vorgangs in einem mehrzeiligen Detailbereich.", "Hausmeister-Tasks lassen sich manuell löschen; Einträge entfernter Mitgliederakten werden beim nächsten Lauf bereinigt.", "Forderungen besitzen eigene Tabs mit Positionen, Teilzahlungen, GnuCash-Referenzen, Zahlungszuordnungen, Mahnungen und Gebühren.", + "Dropdowns zeigen deutsche Begriffe bei weiterhin englischen Speicher-Keys; der Hausmeisterstatus liegt einheitlich in housekeeper.json.", "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.", diff --git a/src/ccma/services/housekeeper.py b/src/ccma/services/housekeeper.py index 4df270a..2aee33e 100644 --- a/src/ccma/services/housekeeper.py +++ b/src/ccma/services/housekeeper.py @@ -51,8 +51,8 @@ class Housekeeper: def __init__(self, repository: MemberRepository, settings: HousekeeperSettings | None = None): self.repository = repository self.settings = settings or HousekeeperSettings() - self.state_path = repository.root / "hausmeister.json" - self.lock_path = repository.root / ".hausmeister.lock" + self.state_path = repository.root / "housekeeper.json" + self.lock_path = repository.root / ".housekeeper.lock" def run(self, today: date | None = None) -> list[HousekeeperFinding]: current_date = today or date.today() @@ -125,7 +125,7 @@ class Housekeeper: "items": sorted(items.values(), key=lambda item: str(item.get("key", ""))), } ) - write_json_atomic(self.state_path, working) + self._write_state(working) return _open_findings(working["items"]) def delete_task(self, key: str) -> list[HousekeeperFinding]: @@ -142,7 +142,7 @@ class Housekeeper: raise RepositoryError("Nur Hausmeister-Tasks können manuell gelöscht werden.") del items[selected_key] state["items"] = sorted(items.values(), key=lambda value: str(value.get("key", ""))) - write_json_atomic(self.state_path, state) + self._write_state(state) return _open_findings(state["items"]) @staticmethod @@ -343,7 +343,10 @@ class Housekeeper: raise ValueError("ungültige Struktur") return state except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc: - raise RepositoryError(f"hausmeister.json konnte nicht gelesen werden: {exc}") from exc + raise RepositoryError(f"housekeeper.json konnte nicht gelesen werden: {exc}") from exc + + def _write_state(self, state: dict[str, Any]) -> None: + write_json_atomic(self.state_path, state) def _items_by_key(state: dict[str, Any]) -> dict[str, dict[str, Any]]: diff --git a/src/ccma/ui/claim_tab.py b/src/ccma/ui/claim_tab.py index 7bd1dc3..71a0574 100644 --- a/src/ccma/ui/claim_tab.py +++ b/src/ccma/ui/claim_tab.py @@ -19,6 +19,7 @@ from ccma.domain.contributions import ( ) from ccma.domain.dates import date_input_hint, format_date_for_display from ccma.storage.repository import MemberRepository, RepositoryError +from ccma.ui.labels import CLAIM_ITEM_TYPE_LABELS, display_label, storage_key class ClaimTab(ttk.Frame): @@ -181,7 +182,7 @@ class ClaimTab(ttk.Frame): "end", iid=str(item.get("item_id", "")), values=( - item.get("type", ""), + display_label(CLAIM_ITEM_TYPE_LABELS, str(item.get("type", ""))), item.get("description", ""), item.get("quantity", "1"), item.get("unit_price", item.get("amount", "")), @@ -294,7 +295,7 @@ class ItemDialog(_Dialog): def __init__(self, master, repository, member_id, claim_id, on_saved): super().__init__(master, "Forderungsposition hinzufügen", on_saved) self.repository, self.member_id, self.claim_id = repository, member_id, claim_id - self.type_var = tk.StringVar(value="correction") + self.type_var = tk.StringVar(value=CLAIM_ITEM_TYPE_LABELS["correction"]) self.description_var = tk.StringVar() self.quantity_var = tk.StringVar(value="1") self.unit_var = tk.StringVar() @@ -310,7 +311,7 @@ class ItemDialog(_Dialog): ttk.Combobox( self.frame, textvariable=variable, - values=("base", "product", "service", "fee", "discount", "credit", "correction"), + values=list(CLAIM_ITEM_TYPE_LABELS.values()), state="readonly", width=32, ).grid(row=row, column=1, sticky="ew", pady=5) @@ -326,7 +327,7 @@ class ItemDialog(_Dialog): description=self.description_var.get(), quantity=self.quantity_var.get(), unit_price=self.unit_var.get(), - item_type=self.type_var.get(), + item_type=storage_key(CLAIM_ITEM_TYPE_LABELS, self.type_var.get()), ) except RepositoryError as exc: messagebox.showerror("Position konnte nicht gespeichert werden", str(exc), parent=self) diff --git a/src/ccma/ui/labels.py b/src/ccma/ui/labels.py new file mode 100644 index 0000000..644e3b3 --- /dev/null +++ b/src/ccma/ui/labels.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from collections.abc import Mapping + +THEME_LABELS = { + "dark": "Dunkel", + "light": "Hell", +} + +CLAIM_ITEM_TYPE_LABELS = { + "base": "Grundposition", + "product": "Produkt", + "service": "Dienstleistung", + "fee": "Gebühr", + "discount": "Rabatt", + "credit": "Gutschrift", + "correction": "Korrektur", +} + + +def display_label(labels: Mapping[str, str], key: str) -> str: + return labels.get(key, key) + + +def storage_key(labels: Mapping[str, str], label: str) -> str: + return next((key for key, value in labels.items() if value == label), label) diff --git a/src/ccma/ui/member_tab.py b/src/ccma/ui/member_tab.py index 8d1272e..85ae377 100644 --- a/src/ccma/ui/member_tab.py +++ b/src/ccma/ui/member_tab.py @@ -12,6 +12,7 @@ from ccma.domain.dates import age_label, date_input_hint, format_date_for_displa from ccma.domain.models import MEMBERSHIP_STATUS_LABELS as STATUS_LABELS from ccma.domain.models import Event from ccma.storage.repository import MemberRepository, RepositoryError +from ccma.ui.labels import display_label, storage_key class MemberTab(ttk.Frame): @@ -121,7 +122,7 @@ class MemberTab(ttk.Frame): ttk.Combobox( data_tab, textvariable=self.variables["status"], - values=list(STATUS_LABELS), + values=list(STATUS_LABELS.values()), state="readonly", width=39, ).grid(row=len(fields), column=1, sticky="ew", pady=5) @@ -196,7 +197,10 @@ class MemberTab(ttk.Frame): date_fields = {"birth_date", "accepted_at", "membership_started_at"} for key, variable in self.variables.items(): value = getattr(self.member, key) - variable.set(format_date_for_display(value) if key in date_fields else value) + if key == "status": + variable.set(display_label(STATUS_LABELS, str(value))) + else: + variable.set(format_date_for_display(value) if key in date_fields else value) self._refresh_events() self._refresh_contributions() self._refresh_documents() @@ -248,7 +252,10 @@ class MemberTab(ttk.Frame): def _save(self) -> None: for key, variable in self.variables.items(): - setattr(self.member, key, variable.get().strip()) + value = variable.get().strip() + if key == "status": + value = storage_key(STATUS_LABELS, value) + setattr(self.member, key, value) try: self.repository.save_member(self.member) except RepositoryError as exc: diff --git a/src/ccma/ui/options_dialog.py b/src/ccma/ui/options_dialog.py index 63746b5..2d73a55 100644 --- a/src/ccma/ui/options_dialog.py +++ b/src/ccma/ui/options_dialog.py @@ -13,6 +13,7 @@ from ccma.services.intervals import ( from ccma.storage.repository import MemberRepository, RepositoryError, validate_member_number_pattern from ccma.ui.changelog_view import ChangelogView from ccma.ui.icons import IconStore +from ccma.ui.labels import THEME_LABELS, display_label, storage_key class OptionsDialog(tk.Toplevel): @@ -30,7 +31,7 @@ class OptionsDialog(tk.Toplevel): self.icons = IconStore(self) self.store_var = tk.StringVar(value=config.store_path) self.gnucash_var = tk.StringVar(value=config.gnucash_path) - self.theme_var = tk.StringVar(value=config.theme_mode) + self.theme_var = tk.StringVar(value=display_label(THEME_LABELS, config.theme_mode)) self.housekeeper_var = tk.BooleanVar(value=config.run_housekeeper_on_startup) self.birthday_before_var = tk.StringVar(value=str(config.birthday_days_before)) self.birthday_after_var = tk.StringVar(value=str(config.birthday_days_after)) @@ -137,7 +138,7 @@ class OptionsDialog(tk.Toplevel): ttk.Combobox( parent, textvariable=self.theme_var, - values=("dark", "light"), + values=list(THEME_LABELS.values()), state="readonly", width=18, ).grid(row=0, column=1, sticky="w", pady=6) @@ -339,7 +340,7 @@ class OptionsDialog(tk.Toplevel): store_changed = old_store != store self.config_obj.store_path = str(store) self.config_obj.gnucash_path = str(gnucash) if gnucash else "" - self.config_obj.theme_mode = self.theme_var.get() + self.config_obj.theme_mode = storage_key(THEME_LABELS, self.theme_var.get()) self.config_obj.run_housekeeper_on_startup = self.housekeeper_var.get() self.config_obj.birthday_days_before = birthday_before self.config_obj.birthday_days_after = birthday_after diff --git a/tests/test_rules.py b/tests/test_rules.py index 2617e31..01a1ab4 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -36,8 +36,10 @@ def evaluate(context): ) findings = Housekeeper(repository).run(today=date(2026, 6, 21)) - state = json.loads((repository.root / "hausmeister.json").read_text(encoding="utf-8")) + state = json.loads((repository.root / "housekeeper.json").read_text(encoding="utf-8")) + assert not (repository.root / "hausmeister.json").exists() + assert not (repository.root / ".hausmeister.lock").exists() assert any(finding.code == "override_active" for finding in findings) rule = next(item for item in state["rules"] if item["filename"] == "birthdate_check.py") assert rule["source"] == "store-override" @@ -60,7 +62,7 @@ def test_housekeeper_claim_actions_are_idempotent(tmp_path) -> None: first_claims = repository.get_contributions(member.member_id).claims housekeeper.run(today=date(2026, 4, 15)) second_claims = repository.get_contributions(member.member_id).claims - state = json.loads((repository.root / "hausmeister.json").read_text(encoding="utf-8")) + state = json.loads((repository.root / "housekeeper.json").read_text(encoding="utf-8")) assert {claim["claim_key"] for claim in first_claims} == { "admission-fee", @@ -85,7 +87,7 @@ def test_housekeeper_resolves_tasks_not_seen_in_current_run(tmp_path) -> None: member.birth_date = "1990-01-01" repository.save_member(member) housekeeper.run(today=date(2026, 6, 21)) - state = json.loads((repository.root / "hausmeister.json").read_text(encoding="utf-8")) + state = json.loads((repository.root / "housekeeper.json").read_text(encoding="utf-8")) task = next(item for item in state["items"] if item["key"].endswith(":missing")) assert task["status"] == "resolved" @@ -99,14 +101,14 @@ def test_failed_run_does_not_advance_persisted_run_id(tmp_path) -> None: repository.create_member(first_name="Failed", last_name="Rule") housekeeper = Housekeeper(repository) housekeeper.run(today=date(2026, 6, 21)) - state_before = (repository.root / "hausmeister.json").read_bytes() + state_before = (repository.root / "housekeeper.json").read_bytes() (repository.root / "rules" / "broken.py").write_text("this is not python !!!", encoding="utf-8") with pytest.raises(RuleLoadError): housekeeper.run(today=date(2026, 6, 21)) - assert (repository.root / "hausmeister.json").read_bytes() == state_before - assert not (repository.root / ".hausmeister.lock").exists() + assert (repository.root / "housekeeper.json").read_bytes() == state_before + assert not (repository.root / ".housekeeper.lock").exists() def test_broken_contributions_file_creates_task_without_overwriting_file(tmp_path) -> None: @@ -124,7 +126,7 @@ def test_broken_contributions_file_creates_task_without_overwriting_file(tmp_pat assert path.read_bytes() == b"" 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 / "housekeeper.json").read_text(encoding="utf-8")) task = next(item for item in state["items"] if item["code"] == "invalid_member_record") assert task["status"] == "open" @@ -139,7 +141,7 @@ def test_preflight_skips_all_rules_for_broken_member_file(tmp_path) -> None: 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")) + state = json.loads((repository.root / "housekeeper.json").read_text(encoding="utf-8")) assert [finding.code for finding in findings] == ["invalid_member_record"] assert len(state["items"]) == 1 @@ -176,7 +178,7 @@ def test_preflight_task_resolves_after_record_is_repaired(tmp_path) -> None: contributions_path.write_bytes(original) housekeeper.run(today=date(2026, 6, 21)) - state = json.loads((repository.root / "hausmeister.json").read_text(encoding="utf-8")) + state = json.loads((repository.root / "housekeeper.json").read_text(encoding="utf-8")) task = next(item for item in state["items"] if item["code"] == "invalid_member_record") assert task["status"] == "resolved" @@ -192,7 +194,7 @@ def test_housekeeper_task_can_be_deleted_and_returns_on_next_run(tmp_path) -> No finding = next(item for item in findings if item.code == "missing_birth_date") remaining = housekeeper.delete_task(finding.key) - state_after_delete = json.loads((repository.root / "hausmeister.json").read_text(encoding="utf-8")) + state_after_delete = json.loads((repository.root / "housekeeper.json").read_text(encoding="utf-8")) assert remaining == [] assert state_after_delete["run_counter"] == 1 @@ -200,7 +202,7 @@ def test_housekeeper_task_can_be_deleted_and_returns_on_next_run(tmp_path) -> No recreated = housekeeper.run(today=date(2026, 6, 21)) recreated_finding = next(item for item in recreated if item.code == "missing_birth_date") - state_after_run = json.loads((repository.root / "hausmeister.json").read_text(encoding="utf-8")) + state_after_run = json.loads((repository.root / "housekeeper.json").read_text(encoding="utf-8")) recreated_task = next(item for item in state_after_run["items"] if item["key"] == recreated_finding.key) assert recreated_finding.key == finding.key @@ -216,7 +218,7 @@ def test_housekeeper_removes_items_for_deleted_member_directory(tmp_path) -> Non shutil.rmtree(repository.members_root / member.member_id) findings = housekeeper.run(today=date(2026, 6, 21)) - state = json.loads((repository.root / "hausmeister.json").read_text(encoding="utf-8")) + state = json.loads((repository.root / "housekeeper.json").read_text(encoding="utf-8")) assert findings == [] assert not any(item.get("member_id") == member.member_id for item in state["items"]) diff --git a/tests/test_ui_imports.py b/tests/test_ui_imports.py index 57a7f4d..02e6420 100644 --- a/tests/test_ui_imports.py +++ b/tests/test_ui_imports.py @@ -60,3 +60,20 @@ def test_housekeeper_details_are_multiline() -> None: assert rendered.splitlines()[0] == "ERROR · invalid_member_record" assert "Mitgliederakte beschädigt\nFällig:" in rendered assert rendered.endswith("nicht automatisch überschrieben.") + + +def test_german_ui_labels_round_trip_to_english_storage_keys() -> None: + from ccma.domain.models import MEMBERSHIP_STATUS_LABELS + from ccma.ui.labels import ( + CLAIM_ITEM_TYPE_LABELS, + THEME_LABELS, + display_label, + storage_key, + ) + + assert display_label(THEME_LABELS, "dark") == "Dunkel" + assert storage_key(THEME_LABELS, "Hell") == "light" + assert display_label(CLAIM_ITEM_TYPE_LABELS, "credit") == "Gutschrift" + assert storage_key(CLAIM_ITEM_TYPE_LABELS, "Dienstleistung") == "service" + assert display_label(MEMBERSHIP_STATUS_LABELS, "active") == "AKTIV" + assert storage_key(MEMBERSHIP_STATUS_LABELS, "EHRENMITGLIED") == "honorary"