Fix reminder dialog sizing, item editing, and old-repo fee defaults

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 <noreply@anthropic.com>
This commit is contained in:
Marcel Peterkau
2026-08-15 03:13:04 +02:00
co-authored by Claude Sonnet 5
parent a1719cad5e
commit d4dfd0066a
3 changed files with 71 additions and 2 deletions
+8 -1
View File
@@ -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)),
+40 -1
View File
@@ -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("<<TreeviewSelect>>", 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):
+23
View File
@@ -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"] == []