refactor: localize UI labels and store filenames

This commit is contained in:
Marcel Peterkau
2026-06-21 18:25:58 +02:00
parent 80d4d5ef90
commit e7962f77e1
9 changed files with 87 additions and 29 deletions
+2 -2
View File
@@ -36,7 +36,7 @@ On first start, select or create the central member-store directory. The
```text ```text
member-store/ member-store/
├── repository.json ├── repository.json
├── hausmeister.json ├── housekeeper.json
├── rules/ ├── rules/
└── members/ └── members/
└── <uuid>/ └── <uuid>/
@@ -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; trusted sources in this directory. Rules return structured `RuleAction` objects;
CCMA performs all file writes, duplicate checks, audit events, and atomic updates. 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 the pending run ID. A failed run therefore cannot advance the stored counter or
silently resolve existing tasks. silently resolve existing tasks.
+1
View File
@@ -24,6 +24,7 @@
"Der Hausmeister-Tab zeigt den vollständigen Inhalt eines markierten Vorgangs in einem mehrzeiligen Detailbereich.", "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.", "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.", "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.", "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.",
+8 -5
View File
@@ -51,8 +51,8 @@ class Housekeeper:
def __init__(self, repository: MemberRepository, settings: HousekeeperSettings | None = None): def __init__(self, repository: MemberRepository, settings: HousekeeperSettings | None = None):
self.repository = repository self.repository = repository
self.settings = settings or HousekeeperSettings() self.settings = settings or HousekeeperSettings()
self.state_path = repository.root / "hausmeister.json" self.state_path = repository.root / "housekeeper.json"
self.lock_path = repository.root / ".hausmeister.lock" self.lock_path = repository.root / ".housekeeper.lock"
def run(self, today: date | None = None) -> list[HousekeeperFinding]: def run(self, today: date | None = None) -> list[HousekeeperFinding]:
current_date = today or date.today() current_date = today or date.today()
@@ -125,7 +125,7 @@ class Housekeeper:
"items": sorted(items.values(), key=lambda item: str(item.get("key", ""))), "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"]) return _open_findings(working["items"])
def delete_task(self, key: str) -> list[HousekeeperFinding]: 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.") raise RepositoryError("Nur Hausmeister-Tasks können manuell gelöscht werden.")
del items[selected_key] del items[selected_key]
state["items"] = sorted(items.values(), key=lambda value: str(value.get("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"]) return _open_findings(state["items"])
@staticmethod @staticmethod
@@ -343,7 +343,10 @@ class Housekeeper:
raise ValueError("ungültige Struktur") raise ValueError("ungültige Struktur")
return state return state
except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc: 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]]: def _items_by_key(state: dict[str, Any]) -> dict[str, dict[str, Any]]:
+5 -4
View File
@@ -19,6 +19,7 @@ from ccma.domain.contributions import (
) )
from ccma.domain.dates import date_input_hint, format_date_for_display from ccma.domain.dates import date_input_hint, format_date_for_display
from ccma.storage.repository import MemberRepository, RepositoryError from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.labels import CLAIM_ITEM_TYPE_LABELS, display_label, storage_key
class ClaimTab(ttk.Frame): class ClaimTab(ttk.Frame):
@@ -181,7 +182,7 @@ class ClaimTab(ttk.Frame):
"end", "end",
iid=str(item.get("item_id", "")), iid=str(item.get("item_id", "")),
values=( values=(
item.get("type", ""), display_label(CLAIM_ITEM_TYPE_LABELS, str(item.get("type", ""))),
item.get("description", ""), item.get("description", ""),
item.get("quantity", "1"), item.get("quantity", "1"),
item.get("unit_price", item.get("amount", "")), 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): def __init__(self, master, repository, member_id, claim_id, on_saved):
super().__init__(master, "Forderungsposition hinzufügen", on_saved) super().__init__(master, "Forderungsposition hinzufügen", on_saved)
self.repository, self.member_id, self.claim_id = repository, member_id, claim_id 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.description_var = tk.StringVar()
self.quantity_var = tk.StringVar(value="1") self.quantity_var = tk.StringVar(value="1")
self.unit_var = tk.StringVar() self.unit_var = tk.StringVar()
@@ -310,7 +311,7 @@ class ItemDialog(_Dialog):
ttk.Combobox( ttk.Combobox(
self.frame, self.frame,
textvariable=variable, textvariable=variable,
values=("base", "product", "service", "fee", "discount", "credit", "correction"), values=list(CLAIM_ITEM_TYPE_LABELS.values()),
state="readonly", state="readonly",
width=32, width=32,
).grid(row=row, column=1, sticky="ew", pady=5) ).grid(row=row, column=1, sticky="ew", pady=5)
@@ -326,7 +327,7 @@ class ItemDialog(_Dialog):
description=self.description_var.get(), description=self.description_var.get(),
quantity=self.quantity_var.get(), quantity=self.quantity_var.get(),
unit_price=self.unit_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: except RepositoryError as exc:
messagebox.showerror("Position konnte nicht gespeichert werden", str(exc), parent=self) messagebox.showerror("Position konnte nicht gespeichert werden", str(exc), parent=self)
+26
View File
@@ -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)
+10 -3
View File
@@ -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 MEMBERSHIP_STATUS_LABELS as STATUS_LABELS
from ccma.domain.models import Event from ccma.domain.models import Event
from ccma.storage.repository import MemberRepository, RepositoryError from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.labels import display_label, storage_key
class MemberTab(ttk.Frame): class MemberTab(ttk.Frame):
@@ -121,7 +122,7 @@ class MemberTab(ttk.Frame):
ttk.Combobox( ttk.Combobox(
data_tab, data_tab,
textvariable=self.variables["status"], textvariable=self.variables["status"],
values=list(STATUS_LABELS), values=list(STATUS_LABELS.values()),
state="readonly", state="readonly",
width=39, width=39,
).grid(row=len(fields), column=1, sticky="ew", pady=5) ).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"} date_fields = {"birth_date", "accepted_at", "membership_started_at"}
for key, variable in self.variables.items(): for key, variable in self.variables.items():
value = getattr(self.member, key) 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_events()
self._refresh_contributions() self._refresh_contributions()
self._refresh_documents() self._refresh_documents()
@@ -248,7 +252,10 @@ class MemberTab(ttk.Frame):
def _save(self) -> None: def _save(self) -> None:
for key, variable in self.variables.items(): 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: try:
self.repository.save_member(self.member) self.repository.save_member(self.member)
except RepositoryError as exc: except RepositoryError as exc:
+4 -3
View File
@@ -13,6 +13,7 @@ from ccma.services.intervals import (
from ccma.storage.repository import MemberRepository, RepositoryError, validate_member_number_pattern from ccma.storage.repository import MemberRepository, RepositoryError, validate_member_number_pattern
from ccma.ui.changelog_view import ChangelogView from ccma.ui.changelog_view import ChangelogView
from ccma.ui.icons import IconStore from ccma.ui.icons import IconStore
from ccma.ui.labels import THEME_LABELS, display_label, storage_key
class OptionsDialog(tk.Toplevel): class OptionsDialog(tk.Toplevel):
@@ -30,7 +31,7 @@ class OptionsDialog(tk.Toplevel):
self.icons = IconStore(self) self.icons = IconStore(self)
self.store_var = tk.StringVar(value=config.store_path) self.store_var = tk.StringVar(value=config.store_path)
self.gnucash_var = tk.StringVar(value=config.gnucash_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.housekeeper_var = tk.BooleanVar(value=config.run_housekeeper_on_startup)
self.birthday_before_var = tk.StringVar(value=str(config.birthday_days_before)) self.birthday_before_var = tk.StringVar(value=str(config.birthday_days_before))
self.birthday_after_var = tk.StringVar(value=str(config.birthday_days_after)) self.birthday_after_var = tk.StringVar(value=str(config.birthday_days_after))
@@ -137,7 +138,7 @@ class OptionsDialog(tk.Toplevel):
ttk.Combobox( ttk.Combobox(
parent, parent,
textvariable=self.theme_var, textvariable=self.theme_var,
values=("dark", "light"), values=list(THEME_LABELS.values()),
state="readonly", state="readonly",
width=18, width=18,
).grid(row=0, column=1, sticky="w", pady=6) ).grid(row=0, column=1, sticky="w", pady=6)
@@ -339,7 +340,7 @@ class OptionsDialog(tk.Toplevel):
store_changed = old_store != store store_changed = old_store != store
self.config_obj.store_path = str(store) self.config_obj.store_path = str(store)
self.config_obj.gnucash_path = str(gnucash) if gnucash else "" 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.run_housekeeper_on_startup = self.housekeeper_var.get()
self.config_obj.birthday_days_before = birthday_before self.config_obj.birthday_days_before = birthday_before
self.config_obj.birthday_days_after = birthday_after self.config_obj.birthday_days_after = birthday_after
+14 -12
View File
@@ -36,8 +36,10 @@ def evaluate(context):
) )
findings = Housekeeper(repository).run(today=date(2026, 6, 21)) 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) assert any(finding.code == "override_active" for finding in findings)
rule = next(item for item in state["rules"] if item["filename"] == "birthdate_check.py") rule = next(item for item in state["rules"] if item["filename"] == "birthdate_check.py")
assert rule["source"] == "store-override" 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 first_claims = repository.get_contributions(member.member_id).claims
housekeeper.run(today=date(2026, 4, 15)) housekeeper.run(today=date(2026, 4, 15))
second_claims = repository.get_contributions(member.member_id).claims 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} == { assert {claim["claim_key"] for claim in first_claims} == {
"admission-fee", "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" member.birth_date = "1990-01-01"
repository.save_member(member) repository.save_member(member)
housekeeper.run(today=date(2026, 6, 21)) 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")) task = next(item for item in state["items"] if item["key"].endswith(":missing"))
assert task["status"] == "resolved" 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") repository.create_member(first_name="Failed", last_name="Rule")
housekeeper = Housekeeper(repository) housekeeper = Housekeeper(repository)
housekeeper.run(today=date(2026, 6, 21)) 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") (repository.root / "rules" / "broken.py").write_text("this is not python !!!", encoding="utf-8")
with pytest.raises(RuleLoadError): with pytest.raises(RuleLoadError):
housekeeper.run(today=date(2026, 6, 21)) housekeeper.run(today=date(2026, 6, 21))
assert (repository.root / "hausmeister.json").read_bytes() == state_before assert (repository.root / "housekeeper.json").read_bytes() == state_before
assert not (repository.root / ".hausmeister.lock").exists() assert not (repository.root / ".housekeeper.lock").exists()
def test_broken_contributions_file_creates_task_without_overwriting_file(tmp_path) -> None: 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 path.read_bytes() == b""
assert [finding.code for finding in findings] == ["invalid_member_record"] 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") task = next(item for item in state["items"] if item["code"] == "invalid_member_record")
assert task["status"] == "open" 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") member_path.write_text("{", encoding="utf-8")
findings = Housekeeper(repository).run(today=date(2026, 6, 21)) 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 [finding.code for finding in findings] == ["invalid_member_record"]
assert len(state["items"]) == 1 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) contributions_path.write_bytes(original)
housekeeper.run(today=date(2026, 6, 21)) 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") task = next(item for item in state["items"] if item["code"] == "invalid_member_record")
assert task["status"] == "resolved" 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") finding = next(item for item in findings if item.code == "missing_birth_date")
remaining = housekeeper.delete_task(finding.key) 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 remaining == []
assert state_after_delete["run_counter"] == 1 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 = housekeeper.run(today=date(2026, 6, 21))
recreated_finding = next(item for item in recreated if item.code == "missing_birth_date") 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) recreated_task = next(item for item in state_after_run["items"] if item["key"] == recreated_finding.key)
assert recreated_finding.key == 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) shutil.rmtree(repository.members_root / member.member_id)
findings = housekeeper.run(today=date(2026, 6, 21)) 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 findings == []
assert not any(item.get("member_id") == member.member_id for item in state["items"]) assert not any(item.get("member_id") == member.member_id for item in state["items"])
+17
View File
@@ -60,3 +60,20 @@ def test_housekeeper_details_are_multiline() -> None:
assert rendered.splitlines()[0] == "ERROR · invalid_member_record" assert rendered.splitlines()[0] == "ERROR · invalid_member_record"
assert "Mitgliederakte beschädigt\nFällig:" in rendered assert "Mitgliederakte beschädigt\nFällig:" in rendered
assert rendered.endswith("nicht automatisch überschrieben.") 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"