From d4dfd0066a6b0e24662d498e8ee67de8dd42ad75 Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Sat, 15 Aug 2026 02:27:00 +0200 Subject: [PATCH] Fix reminder dialog sizing, item editing, and old-repo fee defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dialog's base class sets resizable(False, False) before this subclass's content (including the preset-populated items table) exists, so its initial size stayed locked to a too-small guess and cut off the bottom. It now explicitly sizes to its actual content after everything, including the selected preset's items, has been built. The items table only supported add/remove -- there was no way to change an already-added row's amount (e.g. after picking the Rücklastschrift preset, its prefilled fee couldn't be adjusted). Selecting a row now loads it into the description/amount fields, and a new "Aktualisieren" button applies edits back to that row. The Rücklastschrift preset label was missing the "Stufe N:" prefix the other presets have, inconsistent for no reason. Also fixed a real gap: repositories created before standard_fee_items existed had no such key in repository.json at all, so Optionen showed an empty Standardpositionen table instead of the built-in defaults. get_reminder_policy() now backfills the defaults when the key is missing entirely, while still respecting a list the board intentionally emptied and saved. Co-Authored-By: Claude Sonnet 5 --- src/ccma/storage/repository.py | 9 +++++++- src/ccma/ui/claim_tab.py | 41 +++++++++++++++++++++++++++++++++- tests/test_reminders.py | 23 +++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/ccma/storage/repository.py b/src/ccma/storage/repository.py index 924f8a3..c78b0a2 100644 --- a/src/ccma/storage/repository.py +++ b/src/ccma/storage/repository.py @@ -2441,12 +2441,19 @@ class MemberRepository: ), key=lambda entry: entry["level"], ) + # Repositories created before this setting existed have no "standard_fee_items" + # key at all -- fall back to the built-in defaults rather than an empty list, but + # once the board has actually saved (and possibly emptied) the list, respect that. + if "standard_fee_items" in policy: + standard_items_raw = policy.get("standard_fee_items") or [] + else: + standard_items_raw = DEFAULT_CONFIGURATION["reminder_policy"]["standard_fee_items"] standard_fee_items = [ { "description": str(entry.get("description", "")), "default_amount": money_text(entry.get("default_amount", "0")), } - for entry in policy.get("standard_fee_items") or [] + for entry in standard_items_raw ] return { "grace_days_after_due": int(policy.get("grace_days_after_due", 7)), diff --git a/src/ccma/ui/claim_tab.py b/src/ccma/ui/claim_tab.py index a24777b..0ef4af7 100644 --- a/src/ccma/ui/claim_tab.py +++ b/src/ccma/ui/claim_tab.py @@ -939,6 +939,14 @@ class ReminderDialog(_Dialog): self._build_items_table(row=6) self._apply_preset() self._buttons(7, self._save) + # Size to fit the fully built content (incl. the preset-populated items table) + # instead of whatever size Tk guessed before everything was in place -- the + # base dialog sets resizable(False, False) before this subclass's content + # exists, so an explicit geometry() is needed or the window stays clipped. + self.update_idletasks() + width, height = self.winfo_reqwidth(), self.winfo_reqheight() + self.geometry(f"{width}x{height}") + self.minsize(width, height) def _build_presets(self) -> dict[str, dict]: presets: dict[str, dict] = {} @@ -951,7 +959,7 @@ class ReminderDialog(_Dialog): "items": [{"description": "Mahngebühr", "amount": money_text(fee)}] if fee > 0 else [], } failed_debit_amount = self._default_amount_for("Rücklastschrift") - presets["Rücklastschrift"] = { + presets[f"Stufe {self.level}: Rücklastschrift"] = { "name": "Rücklastschrift", "payment_deadline_days": 14, "items": [{"description": "Rücklastschriftgebühr", "amount": failed_debit_amount}], @@ -984,6 +992,7 @@ class ReminderDialog(_Dialog): self.items_tree.column("description", width=220, anchor="w") self.items_tree.column("amount", width=100, anchor="w", stretch=False) self.items_tree.grid(row=0, column=0, sticky="ew") + self.items_tree.bind("<>", lambda _event: self._load_selected_item()) controls = ttk.Frame(table_frame) controls.grid(row=1, column=0, sticky="ew", pady=(8, 0)) @@ -1001,6 +1010,7 @@ class ReminderDialog(_Dialog): ttk.Button(controls, text="Position hinzufügen", command=self._add_item).pack( side="left", padx=(0, 8) ) + ttk.Button(controls, text="Aktualisieren", command=self._update_item).pack(side="left", padx=(0, 8)) ttk.Button(controls, text="Position entfernen", command=self._remove_item).pack(side="left") def _prefill_item_amount(self) -> None: @@ -1017,6 +1027,14 @@ class ReminderDialog(_Dialog): "", "end", iid=str(index), values=(item["description"], f"{item['amount']} EUR") ) + def _load_selected_item(self) -> None: + selected = self.items_tree.selection() + if not selected: + return + item = self.items[int(selected[0])] + self.item_description_var.set(item["description"]) + self.item_amount_var.set(item["amount"]) + def _add_item(self) -> None: description = self.item_description_var.get().strip() if not description: @@ -1032,12 +1050,33 @@ class ReminderDialog(_Dialog): self.item_amount_var.set("") self._refresh_items() + def _update_item(self) -> None: + selected = self.items_tree.selection() + if not selected: + messagebox.showerror( + "Auswahl fehlt", "Bitte die zu ändernde Position auswählen.", parent=self + ) + return + description = self.item_description_var.get().strip() + if not description: + messagebox.showerror("Beschreibung fehlt", "Bitte eine Beschreibung angeben.", parent=self) + return + try: + amount = decimal_value(self.item_amount_var.get(), "Betrag") + except ValueError as exc: + messagebox.showerror("Ungültiger Betrag", str(exc), parent=self) + return + self.items[int(selected[0])] = {"description": description, "amount": money_text(amount)} + self._refresh_items() + def _remove_item(self) -> None: selected = self.items_tree.selection() if not selected: messagebox.showerror("Auswahl fehlt", "Bitte eine Position auswählen.", parent=self) return del self.items[int(selected[0])] + self.item_description_var.set("") + self.item_amount_var.set("") self._refresh_items() def _save(self): diff --git a/tests/test_reminders.py b/tests/test_reminders.py index 04130bd..ecca884 100644 --- a/tests/test_reminders.py +++ b/tests/test_reminders.py @@ -1,3 +1,4 @@ +import json from datetime import date, timedelta from decimal import Decimal @@ -278,3 +279,25 @@ def test_reminder_policy_rejects_invalid_input(tmp_path) -> None: levels=[{"name": "Mahnung", "fee": "-1.00", "payment_deadline_days": 14}], standard_fee_items=[], ) + + +def test_reminder_policy_backfills_standard_fee_items_for_old_repositories(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + config_path = tmp_path / "repository.json" + raw = json.loads(config_path.read_text(encoding="utf-8")) + # Simulate a repository created before "standard_fee_items" existed at all. + del raw["reminder_policy"]["standard_fee_items"] + config_path.write_text(json.dumps(raw), encoding="utf-8") + + policy = repository.get_reminder_policy() + assert policy["standard_fee_items"], "should fall back to built-in defaults" + assert any(item["description"] == "Rücklastschriftgebühr" for item in policy["standard_fee_items"]) + + # But once the board explicitly saves an empty list, that choice is respected. + repository.save_reminder_policy( + grace_days_after_due=policy["grace_days_after_due"], + levels=policy["levels"], + standard_fee_items=[], + ) + assert repository.get_reminder_policy()["standard_fee_items"] == []