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
member-store/
├── repository.json
├── hausmeister.json
├── housekeeper.json
├── rules/
└── members/
└── <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;
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.
+1
View File
@@ -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.",
+8 -5
View File
@@ -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]]:
+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.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)
+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 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:
+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.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
+14 -12
View File
@@ -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"])
+17
View File
@@ -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"