feat: add staged reminder workflow

This commit is contained in:
Marcel Peterkau
2026-06-21 18:40:54 +02:00
parent 288b5f6247
commit e6d2f77d1e
9 changed files with 630 additions and 43 deletions
+1
View File
@@ -25,6 +25,7 @@
"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.",
"Mehrstufiger Mahnworkflow mit Hausmeister-Regel, Entwurf, Versandbestätigung, Zahlungsfrist, optionaler Gebühr und Mahnsperre ergänzt.",
"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.",
+105
View File
@@ -0,0 +1,105 @@
from datetime import date, timedelta
from ccma.domain.contributions import claim_balance, claim_status, money_text
from ccma.rules.api import RuleContext, task
RULE_ID = "reminder-due"
ORDER = 60
DEFAULT_POLICY = {
"grace_days_after_due": 7,
"levels": [
{"level": 1, "name": "Zahlungserinnerung", "fee": "0.00", "payment_deadline_days": 14},
{"level": 2, "name": "Erste Mahnung", "fee": "5.00", "payment_deadline_days": 14},
{"level": 3, "name": "Zweite Mahnung", "fee": "5.00", "payment_deadline_days": 14},
],
}
def evaluate(context: RuleContext):
policy = context.repository_config.get("reminder_policy") or DEFAULT_POLICY
levels = sorted(policy.get("levels") or [], key=lambda value: int(value.get("level", 0)))
actions = []
for claim in context.contributions.claims:
claim_id = str(claim.get("claim_id", ""))
if not claim_id or claim_status(context.contributions, claim, today=context.today) not in {
"open",
"partially_paid",
"overdue",
}:
continue
if claim_balance(context.contributions, claim) <= 0 or _hold_is_active(claim, context.today):
continue
reminders = [
item for item in context.contributions.reminders if str(item.get("claim_id", "")) == claim_id
]
sent_levels = {
int(item.get("level", 0)): item for item in reminders if str(item.get("status", "")) == "sent"
}
next_level = next(
(definition for definition in levels if int(definition.get("level", 0)) not in sent_levels),
None,
)
if not next_level:
continue
level = int(next_level.get("level", 0))
trigger_date = _trigger_date(claim, sent_levels, level, policy)
if not trigger_date or context.today < trigger_date:
continue
draft_exists = any(
int(item.get("level", 0)) == level and str(item.get("status", "draft")) in {"draft", "generated"}
for item in reminders
)
name = str(next_level.get("name") or f"Mahnung Stufe {level}")
balance = money_text(claim_balance(context.contributions, claim))
title = (
f"{context.member.display_name}: Mahnungsentwurf wartet auf Versand"
if draft_exists
else f"{context.member.display_name}: {name} fällig"
)
detail = (
f"Forderung: {claim.get('title', claim_id)}. Offener Betrag: {balance} EUR. "
f"Mahnstufe {level}, vorgesehen Gebühr: {next_level.get('fee', '0.00')} EUR."
)
actions.append(
task(
rule_id=RULE_ID,
member=context.member,
key_suffix=f"{claim_id}:level-{level}",
severity="warning",
code="reminder_due",
title=title,
detail=detail,
due_date=trigger_date,
)
)
return actions
def _trigger_date(claim, sent_levels, level: int, policy) -> date | None:
if level == 1:
try:
due_date = date.fromisoformat(str(claim.get("due_date", "")))
except ValueError:
return None
return due_date + timedelta(days=int(policy.get("grace_days_after_due", 7)))
previous = sent_levels.get(level - 1)
if not previous:
return None
try:
return date.fromisoformat(str(previous.get("payment_deadline", "")))
except ValueError:
return None
def _hold_is_active(claim, today: date) -> bool:
hold = claim.get("dunning_hold") or {}
if not hold.get("active"):
return False
until = hold.get("until")
if not until:
return True
try:
return date.fromisoformat(str(until)) >= today
except ValueError:
return True
+172 -19
View File
@@ -4,7 +4,7 @@ import json
import os
import unicodedata
from collections.abc import Iterable
from datetime import date, datetime
from datetime import date, datetime, timedelta
from pathlib import Path
from string import Formatter
from uuid import uuid4
@@ -37,6 +37,29 @@ DEFAULT_CONFIGURATION = {
"pattern": DEFAULT_MEMBER_NUMBER_PATTERN,
},
"member_number_sequences": {},
"reminder_policy": {
"grace_days_after_due": 7,
"levels": [
{
"level": 1,
"name": "Zahlungserinnerung",
"fee": "0.00",
"payment_deadline_days": 14,
},
{
"level": 2,
"name": "Erste Mahnung",
"fee": "5.00",
"payment_deadline_days": 14,
},
{
"level": 3,
"name": "Zweite Mahnung",
"fee": "5.00",
"payment_deadline_days": 14,
},
],
},
"contribution_rules": [
{
"rule_id": "standard-2022",
@@ -392,14 +415,17 @@ class MemberRepository:
)
return allocation
def add_reminder(
def create_reminder_draft(
self,
member_id: str,
claim_id: str,
*,
level: int,
name: str,
payment_deadline_days: int,
detail: str = "",
fee: str = "0",
channel: str = "email",
) -> dict:
if level < 1:
raise RepositoryError("Die Mahnstufe muss mindestens 1 sein.")
@@ -409,41 +435,156 @@ class MemberRepository:
raise RepositoryError(str(exc)) from exc
if selected_fee < 0:
raise RepositoryError("Die Mahngebühr darf nicht negativ sein.")
if payment_deadline_days < 1 or payment_deadline_days > 365:
raise RepositoryError("Die Zahlungsfrist muss zwischen 1 und 365 Tagen liegen.")
data, claim = self.get_claim(member_id, claim_id)
if str(claim.get("status", "")) == "cancelled":
raise RepositoryError("Eine stornierte Forderung kann nicht gemahnt werden.")
if _dunning_hold_is_active(claim):
raise RepositoryError("Für diese Forderung ist eine Mahnsperre aktiv.")
if claim_balance(data, claim) <= 0:
raise RepositoryError("Die Forderung hat keinen offenen Betrag.")
if any(
str(item.get("claim_id", "")) == claim_id
and int(item.get("level", 0)) == level
and str(item.get("status", "draft")) in {"draft", "generated", "sent"}
for item in data.reminders
):
raise RepositoryError(f"Mahnstufe {level} existiert bereits.")
if level > 1 and not any(
str(item.get("claim_id", "")) == claim_id
and int(item.get("level", 0)) == level - 1
and str(item.get("status", "")) == "sent"
for item in data.reminders
):
raise RepositoryError(f"Mahnstufe {level - 1} wurde noch nicht versandt.")
created_at = datetime.now().astimezone().isoformat(timespec="seconds")
reminder = {
"reminder_id": str(uuid4()),
"reminder_key": f"{claim_id}:level-{level}",
"claim_id": claim_id,
"level": level,
"name": name.strip() or f"Mahnung Stufe {level}",
"status": "draft",
"detail": detail.strip(),
"created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"channel": channel.strip() or "email",
"created_at": created_at,
"generated_at": None,
"sent_at": None,
"payment_deadline_days": payment_deadline_days,
"payment_deadline": None,
"balance_snapshot": money_text(claim_balance(data, claim)),
"fee": money_text(selected_fee),
"fee_item_id": None,
"document": {"path": "", "sha256": ""},
}
if selected_fee > 0:
item = {
"item_id": str(uuid4()),
"type": "fee",
"description": f"Mahngebühr Stufe {level}",
"quantity": "1.00",
"unit_price": money_text(selected_fee),
"amount": money_text(selected_fee),
"created_at": reminder["created_at"],
}
materialize_claim_items(claim).append(item)
claim["amount"] = money_text(claim_total(claim))
reminder["fee_item_id"] = item["item_id"]
data.reminders.append(reminder)
self.save_contributions(member_id, data)
self.append_event(
member_id,
event_type="reminder_created",
summary=f"Mahnung Stufe {level} erfasst",
event_type="reminder_draft_created",
summary=f"Mahnungsentwurf erstellt: {reminder['name']}",
references={"claim_id": claim_id, "reminder_id": str(reminder["reminder_id"])},
data={"fee": money_text(selected_fee)},
data={"fee": reminder["fee"], "balance_snapshot": reminder["balance_snapshot"]},
)
return reminder
def mark_reminder_sent(self, member_id: str, claim_id: str, reminder_id: str) -> dict:
data, claim = self.get_claim(member_id, claim_id)
reminder = self._find_reminder(data, claim_id, reminder_id)
if str(reminder.get("status", "")) not in {"draft", "generated"}:
raise RepositoryError("Nur ein Entwurf kann als versandt markiert werden.")
if claim_balance(data, claim) <= 0:
raise RepositoryError("Die Forderung hat keinen offenen Betrag mehr.")
now = datetime.now().astimezone()
selected_fee = decimal_value(reminder.get("fee", "0"), "Mahngebühr")
if selected_fee > 0 and not reminder.get("fee_item_id"):
item = {
"item_id": str(uuid4()),
"type": "fee",
"description": f"Mahngebühr Stufe {reminder.get('level', '')}",
"quantity": "1.00",
"unit_price": money_text(selected_fee),
"amount": money_text(selected_fee),
"created_at": now.isoformat(timespec="seconds"),
}
materialize_claim_items(claim).append(item)
claim["amount"] = money_text(claim_total(claim))
reminder["fee_item_id"] = item["item_id"]
reminder["status"] = "sent"
reminder["sent_at"] = now.isoformat(timespec="seconds")
reminder["payment_deadline"] = (
now.date() + timedelta(days=int(reminder.get("payment_deadline_days", 14)))
).isoformat()
self.save_contributions(member_id, data)
self.append_event(
member_id,
event_type="reminder_sent",
summary=f"Mahnung als versandt markiert: {reminder.get('name', '')}",
references={"claim_id": claim_id, "reminder_id": str(reminder["reminder_id"])},
data={"fee": money_text(selected_fee), "payment_deadline": reminder["payment_deadline"]},
)
return reminder
def cancel_reminder(self, member_id: str, claim_id: str, reminder_id: str) -> None:
data, _claim = self.get_claim(member_id, claim_id)
reminder = self._find_reminder(data, claim_id, reminder_id)
if str(reminder.get("status", "")) == "sent":
raise RepositoryError("Eine bereits versandte Mahnung kann nicht verworfen werden.")
reminder["status"] = "cancelled"
reminder["cancelled_at"] = datetime.now().astimezone().isoformat(timespec="seconds")
self.save_contributions(member_id, data)
self.append_event(
member_id,
event_type="reminder_cancelled",
summary=f"Mahnungsentwurf verworfen: {reminder.get('name', '')}",
references={"claim_id": claim_id, "reminder_id": reminder_id},
)
def set_dunning_hold(
self,
member_id: str,
claim_id: str,
*,
active: bool,
reason: str = "",
until: str = "",
) -> None:
data, claim = self.get_claim(member_id, claim_id)
try:
normalized_until = normalize_date_input(until, "Mahnsperre bis")
except DateValidationError as exc:
raise RepositoryError(str(exc)) from exc
claim["dunning_hold"] = {
"active": active,
"reason": reason.strip(),
"until": normalized_until or None,
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
}
self.save_contributions(member_id, data)
self.append_event(
member_id,
event_type="dunning_hold_changed",
summary="Mahnsperre gesetzt" if active else "Mahnsperre aufgehoben",
references={"claim_id": claim_id},
data={"reason": reason.strip(), "until": normalized_until},
)
@staticmethod
def _find_reminder(data: ContributionData, claim_id: str, reminder_id: str) -> dict:
reminder = next(
(
item
for item in data.reminders
if str(item.get("claim_id", "")) == claim_id
and str(item.get("reminder_id", "")) == reminder_id
),
None,
)
if reminder is None:
raise RepositoryError("Mahnung nicht gefunden.")
return reminder
def cancel_claim(self, member_id: str, claim_id: str) -> None:
data, claim = self.get_claim(member_id, claim_id)
if claim_balance(data, claim) != claim_total(claim):
@@ -677,6 +818,18 @@ def _german_date(value: str) -> str:
return ""
def _dunning_hold_is_active(claim: dict) -> bool:
hold = claim.get("dunning_hold") or {}
if not hold.get("active"):
return False
if not hold.get("until"):
return True
try:
return date.fromisoformat(str(hold["until"])) >= date.today()
except ValueError:
return True
def validate_member_number_pattern(pattern: str) -> None:
selected = pattern.strip()
if not selected:
+177 -20
View File
@@ -19,7 +19,13 @@ 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
from ccma.ui.labels import (
CLAIM_ITEM_TYPE_LABELS,
REMINDER_CHANNEL_LABELS,
REMINDER_STATUS_LABELS,
display_label,
storage_key,
)
class ClaimTab(ttk.Frame):
@@ -86,6 +92,8 @@ class ClaimTab(ttk.Frame):
footer = ttk.Frame(self)
footer.grid(row=3, column=0, sticky="ew", pady=(10, 0))
footer.columnconfigure(0, weight=1)
self.hold_button = ttk.Button(footer, text="Mahnsperre setzen", command=self._toggle_hold)
self.hold_button.grid(row=0, column=0, sticky="w")
self.cancel_button = ttk.Button(footer, text="Forderung stornieren", command=self._cancel_claim)
self.cancel_button.grid(row=0, column=1, sticky="e")
@@ -135,19 +143,34 @@ class ClaimTab(ttk.Frame):
def _build_reminders(self, parent: ttk.Frame) -> None:
parent.columnconfigure(0, weight=1)
parent.rowconfigure(0, weight=1)
self.reminders = ttk.Treeview(parent, columns=("created", "level", "detail", "fee"), show="headings")
self.reminders = ttk.Treeview(
parent,
columns=("created", "level", "name", "status", "deadline", "fee"),
show="headings",
)
for key, title, width in (
("created", "Erstellt", 150),
("level", "Stufe", 80),
("detail", "Details", 430),
("fee", "Gebühr", 100),
("level", "Stufe", 60),
("name", "Mahnung", 230),
("status", "Status", 130),
("deadline", "Zahlungsfrist", 120),
("fee", "Gebühr", 90),
):
self.reminders.heading(key, text=title)
self.reminders.column(key, width=width, anchor="w")
self.reminders.grid(row=0, column=0, sticky="nsew")
ttk.Button(parent, text="Mahnung erfassen", command=self._add_reminder).grid(
row=1, column=0, sticky="e", pady=(10, 0)
self.reminders.bind("<<TreeviewSelect>>", lambda _event: self._update_reminder_buttons())
buttons = ttk.Frame(parent)
buttons.grid(row=1, column=0, sticky="e", pady=(10, 0))
self.discard_reminder_button = ttk.Button(
buttons, text="Entwurf verwerfen", command=self._discard_reminder, state="disabled"
)
self.discard_reminder_button.pack(side="left", padx=(0, 8))
self.send_reminder_button = ttk.Button(
buttons, text="Als versandt markieren", command=self._send_reminder, state="disabled"
)
self.send_reminder_button.pack(side="left", padx=(0, 8))
ttk.Button(buttons, text="Mahnung vorbereiten", command=self._add_reminder).pack(side="left")
def refresh(self) -> None:
try:
@@ -165,11 +188,17 @@ class ClaimTab(ttk.Frame):
self.subtitle_var.set(
f"{member.member_number} · {member.display_name} · Fällig: {due or ''} · {self.claim_id}"
)
hold = self.claim.get("dunning_hold") or {}
if hold.get("active"):
until = format_date_for_display(str(hold.get("until") or "")) or "unbefristet"
reason = str(hold.get("reason") or "ohne Begründung")
self.subtitle_var.set(f"{self.subtitle_var.get()} · MAHNSPERRE bis {until}: {reason}")
self.summary_vars["total"].set(f"{money_text(total)} EUR")
self.summary_vars["paid"].set(f"{money_text(paid)} EUR")
self.summary_vars["balance"].set(f"{money_text(balance)} EUR")
self.summary_vars["status"].set(CLAIM_STATUS_LABELS.get(status, status.upper()))
self.cancel_button.configure(state="disabled" if status == "cancelled" else "normal")
self.hold_button.configure(text="Mahnsperre aufheben" if hold.get("active") else "Mahnsperre setzen")
self._render_positions()
self._render_payments()
self._render_reminders()
@@ -217,6 +246,7 @@ class ClaimTab(ttk.Frame):
if str(reminder.get("claim_id", "")) != self.claim_id:
continue
fee_item = items.get(str(reminder.get("fee_item_id", "")), {})
status = str(reminder.get("status", "draft"))
self.reminders.insert(
"",
"end",
@@ -224,10 +254,13 @@ class ClaimTab(ttk.Frame):
values=(
str(reminder.get("created_at", ""))[:16],
reminder.get("level", ""),
reminder.get("detail", ""),
fee_item.get("amount", "0.00"),
reminder.get("name", f"Mahnung Stufe {reminder.get('level', '')}"),
display_label(REMINDER_STATUS_LABELS, status),
format_date_for_display(str(reminder.get("payment_deadline") or "")),
fee_item.get("amount", reminder.get("fee", "0.00")),
),
)
self._update_reminder_buttons()
def _add_item(self) -> None:
ItemDialog(self, self.repository, self.member_id, self.claim_id, self._changed)
@@ -255,6 +288,64 @@ class ClaimTab(ttk.Frame):
def _add_reminder(self) -> None:
ReminderDialog(self, self.repository, self.member_id, self.claim_id, self._changed)
def _selected_reminder(self) -> dict | None:
selected = self.reminders.selection()
if not selected:
return None
return next(
(item for item in self.data.reminders if str(item.get("reminder_id", "")) == selected[0]),
None,
)
def _update_reminder_buttons(self) -> None:
reminder = self._selected_reminder()
editable = bool(reminder and str(reminder.get("status", "draft")) in {"draft", "generated"})
state = "normal" if editable else "disabled"
self.send_reminder_button.configure(state=state)
self.discard_reminder_button.configure(state=state)
def _send_reminder(self) -> None:
reminder = self._selected_reminder()
if not reminder or not messagebox.askyesno(
"Mahnung versandt",
"Wurde diese Mahnung tatsächlich versandt? Erst jetzt werden Frist und Gebühr gebucht.",
parent=self,
):
return
try:
self.repository.mark_reminder_sent(self.member_id, self.claim_id, str(reminder["reminder_id"]))
except RepositoryError as exc:
messagebox.showerror("Versand konnte nicht gespeichert werden", str(exc), parent=self)
return
self._changed()
def _discard_reminder(self) -> None:
reminder = self._selected_reminder()
if not reminder or not messagebox.askyesno(
"Mahnungsentwurf verwerfen", "Diesen Mahnungsentwurf wirklich verwerfen?", parent=self
):
return
try:
self.repository.cancel_reminder(self.member_id, self.claim_id, str(reminder["reminder_id"]))
except RepositoryError as exc:
messagebox.showerror("Entwurf konnte nicht verworfen werden", str(exc), parent=self)
return
self._changed()
def _toggle_hold(self) -> None:
hold = self.claim.get("dunning_hold") or {}
if hold.get("active"):
if not messagebox.askyesno("Mahnsperre aufheben", "Mahnsperre wirklich aufheben?", parent=self):
return
try:
self.repository.set_dunning_hold(self.member_id, self.claim_id, active=False)
except RepositoryError as exc:
messagebox.showerror("Mahnsperre", str(exc), parent=self)
return
self._changed()
return
DunningHoldDialog(self, self.repository, self.member_id, self.claim_id, self._changed)
def _cancel_claim(self) -> None:
if not messagebox.askyesno(
"Forderung stornieren", "Diese Forderung wirklich stornieren?", parent=self
@@ -278,11 +369,11 @@ class _Dialog(tk.Toplevel):
self.on_saved = on_saved
self.title(title)
self.transient(master.winfo_toplevel())
self.grab_set()
self.resizable(False, False)
self.frame = ttk.Frame(self, padding=18)
self.frame.pack(fill="both", expand=True)
self.bind("<Escape>", lambda _event: self.destroy())
self.after_idle(self.grab_set)
def _buttons(self, row: int, command: Callable[[], None]) -> None:
buttons = ttk.Frame(self.frame)
@@ -432,32 +523,98 @@ class AllocatePaymentDialog(_Dialog):
class ReminderDialog(_Dialog):
def __init__(self, master, repository, member_id, claim_id, on_saved):
super().__init__(master, "Mahnung erfassen", on_saved)
super().__init__(master, "Mahnung vorbereiten", on_saved)
self.repository, self.member_id, self.claim_id = repository, member_id, claim_id
self.level_var = tk.IntVar(value=1)
policy = repository.get_configuration().get("reminder_policy") or {}
levels = policy.get("levels") or [
{"level": 1, "name": "Zahlungserinnerung", "fee": "0.00", "payment_deadline_days": 14}
]
self.definition_by_label = {
f"Stufe {item.get('level', '')}: {item.get('name', '')}": item for item in levels
}
self.level_var = tk.StringVar(value=next(iter(self.definition_by_label)))
self.detail_var = tk.StringVar()
self.fee_var = tk.StringVar(value="0.00")
self.fee_var = tk.StringVar()
self.deadline_var = tk.StringVar()
self.channel_var = tk.StringVar(value=REMINDER_CHANNEL_LABELS["email"])
ttk.Label(self.frame, text="Mahnstufe").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Spinbox(self.frame, from_=1, to=99, textvariable=self.level_var, width=8).grid(
row=0, column=1, sticky="w", pady=5
level_combo = ttk.Combobox(
self.frame,
textvariable=self.level_var,
values=list(self.definition_by_label),
state="readonly",
width=38,
)
level_combo.grid(row=0, column=1, sticky="ew", pady=5)
level_combo.bind("<<ComboboxSelected>>", lambda _event: self._load_definition())
ttk.Label(self.frame, text="Details").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Entry(self.frame, textvariable=self.detail_var, width=45).grid(row=1, column=1, pady=5)
ttk.Label(self.frame, text="Mahngebühr").grid(row=2, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Entry(self.frame, textvariable=self.fee_var).grid(row=2, column=1, sticky="ew", pady=5)
self._buttons(3, self._save)
ttk.Label(self.frame, text="Zahlungsfrist in Tagen").grid(
row=3, column=0, sticky="w", pady=5, padx=(0, 12)
)
ttk.Entry(self.frame, textvariable=self.deadline_var).grid(row=3, column=1, sticky="ew", pady=5)
ttk.Label(self.frame, text="Versandweg").grid(row=4, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Combobox(
self.frame,
textvariable=self.channel_var,
values=list(REMINDER_CHANNEL_LABELS.values()),
state="readonly",
).grid(row=4, column=1, sticky="ew", pady=5)
self._load_definition()
self._buttons(5, self._save)
def _load_definition(self) -> None:
definition = self.definition_by_label[self.level_var.get()]
self.fee_var.set(str(definition.get("fee", "0.00")))
self.deadline_var.set(str(definition.get("payment_deadline_days", 14)))
def _save(self):
definition = self.definition_by_label[self.level_var.get()]
try:
self.repository.add_reminder(
self.repository.create_reminder_draft(
self.member_id,
self.claim_id,
level=self.level_var.get(),
level=int(definition.get("level", 1)),
name=str(definition.get("name", "Mahnung")),
payment_deadline_days=int(self.deadline_var.get()),
detail=self.detail_var.get(),
fee=self.fee_var.get(),
channel=storage_key(REMINDER_CHANNEL_LABELS, self.channel_var.get()),
)
except (tk.TclError, RepositoryError) as exc:
messagebox.showerror("Mahnung konnte nicht gespeichert werden", str(exc), parent=self)
except (ValueError, RepositoryError) as exc:
messagebox.showerror("Mahnungsentwurf konnte nicht gespeichert werden", str(exc), parent=self)
return
self.destroy()
self.on_saved()
class DunningHoldDialog(_Dialog):
def __init__(self, master, repository, member_id, claim_id, on_saved):
super().__init__(master, "Mahnsperre setzen", on_saved)
self.repository, self.member_id, self.claim_id = repository, member_id, claim_id
self.reason_var = tk.StringVar()
self.until_var = tk.StringVar()
ttk.Label(self.frame, text="Grund").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Entry(self.frame, textvariable=self.reason_var, width=45).grid(row=0, column=1, pady=5)
ttk.Label(self.frame, text=f"Bis ({date_input_hint()}, optional)").grid(
row=1, column=0, sticky="w", pady=5, padx=(0, 12)
)
ttk.Entry(self.frame, textvariable=self.until_var).grid(row=1, column=1, sticky="ew", pady=5)
self._buttons(2, self._save)
def _save(self) -> None:
try:
self.repository.set_dunning_hold(
self.member_id,
self.claim_id,
active=True,
reason=self.reason_var.get(),
until=self.until_var.get(),
)
except RepositoryError as exc:
messagebox.showerror("Mahnsperre konnte nicht gespeichert werden", str(exc), parent=self)
return
self.destroy()
self.on_saved()
+13
View File
@@ -17,6 +17,19 @@ CLAIM_ITEM_TYPE_LABELS = {
"correction": "Korrektur",
}
REMINDER_STATUS_LABELS = {
"draft": "Entwurf",
"generated": "Dokument erzeugt",
"sent": "Versandt",
"cancelled": "Verworfen",
}
REMINDER_CHANNEL_LABELS = {
"email": "E-Mail",
"letter": "Brief",
"personal": "Persönlich",
}
def display_label(labels: Mapping[str, str], key: str) -> str:
return labels.get(key, key)