Files
CCMA/src/ccma/ui/work_tabs.py
T
Marcel PeterkauandClaude Opus 5 9e9bb7d668 Ask every member to check the data the club stores about them
The club has to keep its member data current, and until now that meant writing
to each member by hand. "Datenüberprüfung anfragen" in the members tab sends one
mail per member, each listing that member's own record: number, name, nickname,
birth date, contact data, address, status, member since, payment frequency, and
the bank details only for members who have any.

Two decisions the record itself forced:

A field with no value is printed as "(nicht hinterlegt)" rather than left out.
The point of the mail is to have gaps filled in, and a missing line is a gap
nobody sees.

The IBAN is masked down to its country code and last four digits. That is enough
to recognise the account, and it keeps a full account number out of a mail the
club sends to dozens of people at once.

Every member is listed as a recipient, with the live memberships that have an
address preselected -- a member who resigned at year's end may still need to
confirm their address, so the board can add them by hand. Members without an
address are skipped and reported instead of failing the run.

Delivery reuses the existing mail machinery: the configured delivery mode, one
SMTP/IMAP connection for the whole run, an archive copy in the member file, a
"data_review_email_sent" event, and the read-only guard before anything is
rendered. A member whose mail fails is reported as a warning and the run
continues -- one bad address must not stop a mailing to the whole club halfway
through. Because the run cannot be taken back, the recipient count is confirmed
once more before it starts.

Subject and text come from a new "Datenüberprüfung" template, editable like the
others, with {{data.sheet}} for the whole record and a {{#data}} block for a
layout of the board's own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:05:38 +02:00

717 lines
27 KiB
Python

import tkinter as tk
from collections import Counter
from collections.abc import Callable
from tkinter import messagebox, ttk
from ccma.domain.dates import format_date_for_display
from ccma.domain.models import (
ASSET_STATUS_LABELS,
MEMBERSHIP_STATUS_LABELS,
Asset,
HousekeeperFinding,
Member,
)
from ccma.ui.labels import storage_key
from ccma.ui.sections import titled_frame
MEMBER_TABLE_COLUMNS = (
("number", "Nummer", 110),
("last_name", "Nachname", 180),
("first_name", "Vorname", 160),
("nickname", "Nickname", 160),
("email", "E-Mail-Adresse", 270),
("birth", "Geburtsdatum", 120),
("status", "Status", 170),
)
STATUS_FILTER_ALL = "Alle"
MEMBER_FINDING_COLORS = {
"info": "#d9efff",
"warning": "#fff3b0",
"error": "#ffd0d0",
}
MEMBER_FINDING_PRIORITY = {"info": 1, "warning": 2, "error": 3}
ASSET_FILTER_ALL = "Alle"
ASSET_TABLE_COLUMNS = (
("label", "Bezeichnung", 260),
("category", "Kategorie", 140),
("inventory_number", "Inventarnummer", 140),
("status", "Status", 140),
("owner", "Eigentümer", 240),
("holder", "Gewahrsam", 240),
("location", "Standort", 180),
)
def _member_table_value(member: Member, column: str) -> str:
if column == "number":
return member.member_number
if column == "first_name":
return member.first_name
if column == "last_name":
return member.last_name
if column == "nickname":
return member.nickname
if column == "email":
return member.email
if column == "birth":
return member.birth_date
if column == "status":
return MEMBERSHIP_STATUS_LABELS.get(member.status, member.status)
return ""
def _filter_members(members: list[Member], status_filter: str) -> list[Member]:
if status_filter == "all":
return list(members)
return [member for member in members if member.status == status_filter]
def _sort_members(members: list[Member], column: str, descending: bool) -> list[Member]:
return sorted(
members,
key=lambda member: _member_table_value(member, column).casefold(),
reverse=descending,
)
def _selected_status_filter(label: str) -> str:
if label == STATUS_FILTER_ALL:
return "all"
return storage_key(MEMBERSHIP_STATUS_LABELS, label)
def _member_finding_severities(findings: list[HousekeeperFinding]) -> dict[str, str]:
severities: dict[str, str] = {}
for finding in findings:
if not finding.member_id or finding.severity not in MEMBER_FINDING_PRIORITY:
continue
current = severities.get(finding.member_id)
if current is None or MEMBER_FINDING_PRIORITY[finding.severity] > MEMBER_FINDING_PRIORITY[current]:
severities[finding.member_id] = finding.severity
return severities
def _filter_label_frame(parent: tk.Misc) -> ttk.LabelFrame:
return titled_frame(parent, "// FILTER")
def _asset_table_value(asset: Asset, column: str, holder_label: str, owner_label: str = "") -> str:
if column == "label":
return asset.label
if column == "category":
return asset.category
if column == "inventory_number":
return asset.inventory_number
if column == "status":
return ASSET_STATUS_LABELS.get(asset.status, asset.status)
if column == "holder":
return holder_label
if column == "owner":
return owner_label
if column == "location":
return asset.location
return ""
def _filter_assets(assets: list[Asset], status_filter: str) -> list[Asset]:
if status_filter == "all":
return list(assets)
return [asset for asset in assets if asset.status == status_filter]
class DashboardTab(ttk.Frame):
def __init__(
self,
master: tk.Misc,
member_count: int,
findings: list[HousekeeperFinding],
on_housekeeper: Callable[[], None],
):
super().__init__(master, padding=24)
self.member_count = member_count
self.findings = findings
self.on_housekeeper = on_housekeeper
self._build_ui()
def _build_ui(self) -> None:
self.columnconfigure(0, weight=1)
ttk.Label(self, text="SYSTEM OVERVIEW", style="TabTitle.TLabel").grid(row=0, column=0, sticky="w")
ttk.Label(self, text="Mitgliederverwaltung · lokaler File-Store", style="Mono.TLabel").grid(
row=1, column=0, sticky="w", pady=(3, 22)
)
cards = ttk.Frame(self)
cards.grid(row=2, column=0, sticky="ew")
counts = Counter(finding.severity for finding in self.findings)
values = [
("MITGLIEDER", str(self.member_count), ""),
("ACTION REQUIRED", str(counts["error"]), "CardError.TLabel"),
("DUE SOON", str(counts["warning"] + counts["info"]), "CardWarning.TLabel"),
("DATA INTEGRITY", "OK", ""),
]
for column, (label, value, style) in enumerate(values):
card = ttk.Frame(cards, style="Card.TFrame", padding=18)
card.grid(row=0, column=column, sticky="nsew", padx=(0, 10))
cards.columnconfigure(column, weight=1)
ttk.Label(card, text=label, style="CardTitle.TLabel").pack(anchor="w")
ttk.Label(card, text=value, style=style or "CardValue.TLabel").pack(anchor="w", pady=(8, 0))
ttk.Button(self, text="Hausmeister öffnen", style="Accent.TButton", command=self.on_housekeeper).grid(
row=3, column=0, sticky="w", pady=(24, 0)
)
def update_data(self, member_count: int, findings: list[HousekeeperFinding]) -> None:
self.member_count = member_count
self.findings = findings
for child in self.winfo_children():
child.destroy()
self._build_ui()
class SearchResultsTab(ttk.Frame):
def __init__(
self,
master: tk.Misc,
query: str,
members: list[Member],
on_open: Callable[[str], None],
on_close: Callable[[], None],
):
super().__init__(master, padding=12)
self.query = query
self.members = members
self.on_open = on_open
self.on_close = on_close
self._build_ui()
def _build_ui(self) -> None:
self.columnconfigure(0, weight=1)
self.rowconfigure(1, weight=1)
header = ttk.Frame(self)
header.grid(row=0, column=0, sticky="ew", pady=(0, 10))
header.columnconfigure(0, weight=1)
ttk.Label(header, text=f'Suche: "{self.query}"', style="TabTitle.TLabel").grid(
row=0, column=0, sticky="w"
)
ttk.Label(header, text=f"{len(self.members)} Treffer", style="Mono.TLabel").grid(
row=1, column=0, sticky="w"
)
ttk.Button(header, text="Tab schließen", command=self.on_close).grid(row=0, column=3, rowspan=2)
tree = ttk.Treeview(
self,
columns=("number", "first_name", "last_name", "nickname", "email", "birth", "status"),
show="headings",
)
for key, title, width in (
("number", "Nummer", 90),
("first_name", "Vorname", 150),
("last_name", "Nachname", 170),
("nickname", "Nickname", 150),
("email", "E-Mail-Adresse", 260),
("birth", "Geburtsdatum", 110),
("status", "Status", 160),
):
tree.heading(key, text=title)
tree.column(key, width=width, anchor="w")
tree.grid(row=1, column=0, sticky="nsew")
for member in self.members:
tree.insert(
"",
"end",
iid=member.member_id,
values=(
member.member_number,
member.first_name,
member.last_name,
member.nickname,
member.email,
format_date_for_display(member.birth_date),
MEMBERSHIP_STATUS_LABELS.get(member.status, member.status),
),
)
tree.bind("<Double-1>", lambda _event: self._open_selected(tree))
tree.bind("<Return>", lambda _event: self._open_selected(tree))
def _open_selected(self, tree: ttk.Treeview) -> None:
selected = tree.selection()
if selected:
self.on_open(selected[0])
class MembersTab(ttk.Frame):
def __init__(
self,
master: tk.Misc,
members: list[Member],
findings: list[HousekeeperFinding],
on_open: Callable[[str], None],
on_close: Callable[[], None],
on_data_review: Callable[[], None] | None = None,
):
super().__init__(master, padding=12)
self.members = members
self.findings = findings
self.on_open = on_open
self.on_close = on_close
self.on_data_review = on_data_review
self._build_ui()
def _build_ui(self) -> None:
self.columnconfigure(0, weight=1)
self.rowconfigure(2, weight=1)
header = ttk.Frame(self)
header.grid(row=0, column=0, sticky="ew", pady=(0, 10))
header.columnconfigure(0, weight=1)
ttk.Label(header, text="MITGLIEDER", style="TabTitle.TLabel").grid(row=0, column=0, sticky="w")
self.count_var = tk.StringVar()
ttk.Label(header, textvariable=self.count_var, style="Mono.TLabel").grid(row=1, column=0, sticky="w")
buttons = ttk.Frame(header)
buttons.grid(row=0, column=1, rowspan=2, sticky="e")
if self.on_data_review is not None:
# The mailing goes to every member, so it belongs where every member is
# listed rather than into the already crowded ribbon.
ttk.Button(
buttons, text="Datenüberprüfung anfragen", command=self.on_data_review
).pack(side="left", padx=(0, 8))
ttk.Button(buttons, text="Tab schließen", command=self.on_close).pack(side="left")
filters = _filter_label_frame(self)
filters.grid(row=1, column=0, sticky="ew", pady=(0, 10))
filter_row = ttk.Frame(filters)
filter_row.grid(row=0, column=0, sticky="w")
self.tree = ttk.Treeview(
self,
columns=tuple(column for column, _title, _width in MEMBER_TABLE_COLUMNS),
show="headings",
)
self.sort_column = "last_name"
self.sort_descending = False
self.status_filter_var = tk.StringVar(value=MEMBERSHIP_STATUS_LABELS["active"])
ttk.Label(filter_row, text="Status").grid(row=0, column=0, sticky="w", padx=(0, 8))
self.status_filter = ttk.Combobox(
filter_row,
textvariable=self.status_filter_var,
state="readonly",
values=[STATUS_FILTER_ALL, *MEMBERSHIP_STATUS_LABELS.values()],
width=28,
)
self.status_filter.grid(row=0, column=1, sticky="w")
self.status_filter.bind("<<ComboboxSelected>>", lambda _event: self._render_members())
for key, title, width in MEMBER_TABLE_COLUMNS:
self.tree.heading(key, text=title, command=lambda column=key: self._toggle_sort(column))
self.tree.column(key, width=width, anchor="w")
for severity, color in MEMBER_FINDING_COLORS.items():
self.tree.tag_configure(f"finding-{severity}", background=color, foreground="#202020")
self.tree.grid(row=2, column=0, sticky="nsew")
self.tree.bind("<Double-1>", lambda _event: self._open_selected())
self.tree.bind("<Return>", lambda _event: self._open_selected())
self.refresh(self.members, self.findings)
def refresh(self, members: list[Member], findings: list[HousekeeperFinding]) -> None:
self.members = members
self.findings = findings
self._render_members()
def _render_members(self) -> None:
self.tree.delete(*self.tree.get_children())
status_filter = _selected_status_filter(self.status_filter_var.get())
filtered_members = _filter_members(self.members, status_filter)
sorted_members = _sort_members(filtered_members, self.sort_column, self.sort_descending)
finding_severities = _member_finding_severities(self.findings)
if len(filtered_members) == len(self.members):
self.count_var.set(f"{len(filtered_members)} Mitglieder")
else:
self.count_var.set(f"{len(filtered_members)} / {len(self.members)} Mitglieder")
self._update_tree_headings()
for member in sorted_members:
severity = finding_severities.get(member.member_id)
self.tree.insert(
"",
"end",
iid=member.member_id,
tags=(f"finding-{severity}",) if severity else (),
values=(
member.member_number,
member.last_name,
member.first_name,
member.nickname,
member.email,
format_date_for_display(member.birth_date),
MEMBERSHIP_STATUS_LABELS.get(member.status, member.status),
),
)
def _toggle_sort(self, column: str) -> None:
if self.sort_column == column:
self.sort_descending = not self.sort_descending
else:
self.sort_column = column
self.sort_descending = False
self._render_members()
def _update_tree_headings(self) -> None:
for key, title, _width in MEMBER_TABLE_COLUMNS:
suffix = ""
if key == self.sort_column:
suffix = " v" if self.sort_descending else " ^"
self.tree.heading(
key,
text=f"{title}{suffix}",
command=lambda column=key: self._toggle_sort(column),
)
def _open_selected(self) -> None:
selected = self.tree.selection()
if selected:
self.on_open(selected[0])
class AssetsTab(ttk.Frame):
def __init__(
self,
master: tk.Misc,
assets: list[Asset],
resolve_holder_label: Callable[[str], str],
on_new: Callable[[], None],
on_open: Callable[[str], None],
on_edit: Callable[[str], None],
on_issue: Callable[[str], None],
on_return: Callable[[str], None],
on_close: Callable[[], None],
):
super().__init__(master, padding=12)
self.assets = assets
self.resolve_holder_label = resolve_holder_label
self.on_new = on_new
self.on_open = on_open
self.on_edit = on_edit
self.on_issue = on_issue
self.on_return = on_return
self.on_close = on_close
self.sort_column = "label"
self.sort_descending = False
self.status_filter_var = tk.StringVar(value=ASSET_FILTER_ALL)
self._build_ui()
def _build_ui(self) -> None:
self.columnconfigure(0, weight=1)
self.rowconfigure(2, weight=1)
header = ttk.Frame(self)
header.grid(row=0, column=0, sticky="ew", pady=(0, 10))
header.columnconfigure(0, weight=1)
ttk.Label(header, text="INVENTAR", style="TabTitle.TLabel").grid(row=0, column=0, sticky="w")
self.count_var = tk.StringVar()
ttk.Label(header, textvariable=self.count_var, style="Mono.TLabel").grid(row=1, column=0, sticky="w")
button_row = ttk.Frame(header)
button_row.grid(row=0, column=1, rowspan=2, sticky="e")
ttk.Button(button_row, text="Neues Asset", command=self.on_new).pack(side="left", padx=(0, 8))
ttk.Button(button_row, text="Tab schließen", command=self.on_close).pack(side="left")
filters = _filter_label_frame(self)
filters.grid(row=1, column=0, sticky="ew", pady=(0, 10))
filter_row = ttk.Frame(filters)
filter_row.grid(row=0, column=0, sticky="w")
ttk.Label(filter_row, text="Status").grid(row=0, column=0, sticky="w", padx=(0, 8))
self.status_filter = ttk.Combobox(
filter_row,
textvariable=self.status_filter_var,
state="readonly",
values=[ASSET_FILTER_ALL, *ASSET_STATUS_LABELS.values()],
width=22,
)
self.status_filter.grid(row=0, column=1, sticky="w")
self.status_filter.bind("<<ComboboxSelected>>", lambda _event: self._render_assets())
self.tree = ttk.Treeview(
self,
columns=tuple(key for key, _title, _width in ASSET_TABLE_COLUMNS),
show="headings",
)
for key, title, width in ASSET_TABLE_COLUMNS:
self.tree.heading(key, text=title, command=lambda column=key: self._toggle_sort(column))
self.tree.column(key, width=width, anchor="w")
self.tree.grid(row=2, column=0, sticky="nsew")
self.tree.bind("<Double-1>", self._open_selected)
self.tree.bind("<<TreeviewSelect>>", lambda _event: self._update_actions())
actions = ttk.Frame(self)
actions.grid(row=3, column=0, sticky="e", pady=(10, 0))
self.edit_button = ttk.Button(
actions,
text="Bearbeiten",
command=self._edit_selected,
state="disabled",
)
self.edit_button.pack(side="left", padx=(0, 8))
self.open_button = ttk.Button(actions, text="Öffnen", command=self._open_selected, state="disabled")
self.open_button.pack(side="left", padx=(0, 8))
self.issue_button = ttk.Button(
actions,
text="Ausgeben",
command=self._issue_selected,
state="disabled",
)
self.issue_button.pack(side="left", padx=(0, 8))
self.return_button = ttk.Button(
actions,
text="Zurücknehmen",
command=self._return_selected,
state="disabled",
)
self.return_button.pack(side="left")
self.refresh(self.assets)
def refresh(self, assets: list[Asset]) -> None:
self.assets = assets
self._render_assets()
def _render_assets(self) -> None:
self.tree.delete(*self.tree.get_children())
status_filter = _selected_asset_filter(self.status_filter_var.get())
filtered_assets = _filter_assets(self.assets, status_filter)
sorted_assets = sorted(
filtered_assets,
key=lambda asset: _asset_table_value(
asset,
self.sort_column,
self.resolve_holder_label(asset.current_holder_member_id),
self._owner_label(asset),
).casefold(),
reverse=self.sort_descending,
)
if len(filtered_assets) == len(self.assets):
self.count_var.set(f"{len(filtered_assets)} Assets")
else:
self.count_var.set(f"{len(filtered_assets)} / {len(self.assets)} Assets")
self._update_tree_headings()
for asset in sorted_assets:
self.tree.insert(
"",
"end",
iid=asset.asset_id,
values=(
asset.label,
asset.category,
asset.inventory_number,
ASSET_STATUS_LABELS.get(asset.status, asset.status),
self._owner_label(asset),
self.resolve_holder_label(asset.current_holder_member_id),
asset.location,
),
)
self._update_actions()
def _owner_label(self, asset: Asset) -> str:
if asset.owner_type == "club":
return "Verein"
if asset.owner_type == "member":
return self.resolve_holder_label(asset.owner_member_id)
return asset.owner_name or "Extern"
def _toggle_sort(self, column: str) -> None:
if self.sort_column == column:
self.sort_descending = not self.sort_descending
else:
self.sort_column = column
self.sort_descending = False
self._render_assets()
def _update_tree_headings(self) -> None:
for key, title, _width in ASSET_TABLE_COLUMNS:
suffix = ""
if key == self.sort_column:
suffix = " v" if self.sort_descending else " ^"
self.tree.heading(
key,
text=f"{title}{suffix}",
command=lambda column=key: self._toggle_sort(column),
)
def _selected_asset_id(self) -> str:
selected = self.tree.selection()
return selected[0] if selected else ""
def _selected_asset(self) -> Asset | None:
selected_asset_id = self._selected_asset_id()
return next((asset for asset in self.assets if asset.asset_id == selected_asset_id), None)
def _update_actions(self) -> None:
asset = self._selected_asset()
if asset is None:
self.edit_button.configure(state="disabled")
self.open_button.configure(state="disabled")
self.issue_button.configure(state="disabled")
self.return_button.configure(state="disabled")
return
self.edit_button.configure(state="normal")
self.open_button.configure(state="normal")
self.issue_button.configure(state="normal" if asset.status == "available" else "disabled")
self.return_button.configure(state="normal" if asset.current_holder_member_id else "disabled")
def _open_selected(self, _event: tk.Event | None = None) -> None:
asset_id = self._selected_asset_id()
if not asset_id:
asset_id = self.tree.focus()
if asset_id:
self.on_open(asset_id)
def _edit_selected(self, _event: tk.Event | None = None) -> None:
asset_id = self._selected_asset_id()
if not asset_id:
asset_id = self.tree.focus()
if asset_id:
self.on_edit(asset_id)
def _issue_selected(self, _event: tk.Event | None = None) -> None:
asset_id = self._selected_asset_id()
if not asset_id:
asset_id = self.tree.focus()
if asset_id:
self.on_issue(asset_id)
def _return_selected(self) -> None:
asset_id = self._selected_asset_id()
if asset_id:
self.on_return(asset_id)
class HousekeeperTab(ttk.Frame):
def __init__(
self,
master: tk.Misc,
findings: list[HousekeeperFinding],
on_open_target: Callable[[HousekeeperFinding], None],
on_refresh: Callable[[], list[HousekeeperFinding]],
on_delete: Callable[[str], list[HousekeeperFinding]],
on_close: Callable[[], None],
):
super().__init__(master, padding=12)
self.findings = findings
self.on_open_target = on_open_target
self.on_refresh = on_refresh
self.on_delete = on_delete
self.on_close = on_close
self._build_ui()
def _build_ui(self) -> None:
self.columnconfigure(0, weight=1)
self.rowconfigure(1, weight=1)
header = ttk.Frame(self)
header.grid(row=0, column=0, sticky="ew", pady=(0, 10))
header.columnconfigure(0, weight=1)
self.title_var = tk.StringVar()
ttk.Label(header, textvariable=self.title_var, style="TabTitle.TLabel").grid(
row=0, column=0, sticky="w"
)
ttk.Label(
header,
text="Regeln prüfen Daten und führen idempotente Aktionen aus",
style="Mono.TLabel",
).grid(row=1, column=0, sticky="w")
ttk.Button(header, text="Neu prüfen", command=self.refresh).grid(
row=0, column=1, rowspan=2, padx=(0, 8)
)
ttk.Button(header, text="Tab schließen", command=self.on_close).grid(row=0, column=2, rowspan=2)
self.tree = ttk.Treeview(self, columns=("severity", "due", "title", "detail"), show="headings")
for key, title, width in (
("severity", "Level", 90),
("due", "Fällig", 100),
("title", "Vorgang", 330),
("detail", "Details", 390),
):
self.tree.heading(key, text=title)
self.tree.column(key, width=width, anchor="w", stretch=key == "detail")
self.tree.grid(row=1, column=0, sticky="nsew")
self.tree.bind("<Double-1>", lambda _event: self._open_selected())
self.tree.bind("<<TreeviewSelect>>", lambda _event: self._show_selected_details())
details = ttk.LabelFrame(self, text="Details", padding=12)
details.grid(row=2, column=0, sticky="ew", pady=(10, 0))
details.columnconfigure(0, weight=1)
self.detail_var = tk.StringVar(value="Eintrag auswählen, um Details anzuzeigen.")
self.detail_label = ttk.Label(
details,
textvariable=self.detail_var,
anchor="nw",
justify="left",
wraplength=800,
style="Status.TLabel",
)
self.detail_label.grid(row=0, column=0, sticky="ew")
self.delete_button = ttk.Button(
details,
text="Task löschen",
command=self._delete_selected,
state="disabled",
)
self.delete_button.grid(row=0, column=1, sticky="ne", padx=(12, 0))
details.bind(
"<Configure>",
lambda event: self.detail_label.configure(wraplength=max(300, event.width - 32)),
)
self._render()
def refresh(self) -> None:
self.findings = self.on_refresh()
self._render()
def _render(self) -> None:
self.tree.delete(*self.tree.get_children())
self.detail_var.set("Eintrag auswählen, um Details anzuzeigen.")
self.delete_button.configure(state="disabled")
self.title_var.set(f"HAUSMEISTER · {len(self.findings)} Vorgänge")
for index, finding in enumerate(self.findings):
self.tree.insert(
"",
"end",
iid=str(index),
values=(finding.severity.upper(), finding.due_date or "", finding.title, finding.detail),
)
def _show_selected_details(self) -> None:
selected = self.tree.selection()
if not selected:
self.detail_var.set("Eintrag auswählen, um Details anzuzeigen.")
self.delete_button.configure(state="disabled")
return
index = int(selected[0])
if index >= len(self.findings):
return
self.detail_var.set(_finding_details(self.findings[index]))
self.delete_button.configure(state="normal" if self.findings[index].key else "disabled")
def _delete_selected(self) -> None:
selected = self.tree.selection()
if not selected:
return
finding = self.findings[int(selected[0])]
if not finding.key or not messagebox.askyesno(
"Hausmeister-Task löschen",
f"Task wirklich löschen?\n\n{finding.title}\n\n"
"Falls die Ursache weiter besteht, wird er beim nächsten Lauf neu angelegt.",
parent=self,
):
return
self.findings = self.on_delete(finding.key)
self._render()
def _open_selected(self) -> None:
selected = self.tree.selection()
if selected:
self.on_open_target(self.findings[int(selected[0])])
def _finding_details(finding: HousekeeperFinding) -> str:
lines = [f"{finding.severity.upper()} · {finding.code}", finding.title]
if finding.target_type == "asset" and finding.asset_id:
lines.append(f"Asset: {finding.asset_id}")
elif finding.member_id:
lines.append(f"Mitglied: {finding.member_id}")
if finding.key:
lines.append(f"Key: {finding.key}")
if finding.due_date:
lines.append(f"Fällig: {format_date_for_display(finding.due_date.isoformat())}")
if finding.detail:
lines.extend(("", finding.detail))
return "\n".join(lines)
def _selected_asset_filter(label: str) -> str:
if label == ASSET_FILTER_ALL:
return "all"
return storage_key(ASSET_STATUS_LABELS, label)