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"] == []