mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-06-30 18:54:51 +02:00
refactor: localize UI labels and store filenames
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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"])
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user