schedule_action_view.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687
  1. # -*- coding: utf-8 -*-
  2. """
  3. Schedule SubView: Actions (F4).
  4. Interaktive Verwaltung der Aktionen eines Schedule-Jobs.
  5. Ermoeglicht Hinzufuegen, Entfernen, Bearbeiten und Umsortieren
  6. von Aktionen mit dynamischen Formularfeldern je nach Aktionstyp.
  7. """
  8. from __future__ import annotations
  9. from typing import Any
  10. from textual import on
  11. from textual.app import ComposeResult
  12. from textual.containers import Vertical, Horizontal
  13. from textual.widgets import Static, Input, Switch, Button, Select, DataTable, TextArea
  14. from trixy_core.tui.views.base import MainView
  15. # Wochentag-Labels fuer Weekday-Switches
  16. _WEEKDAY_LABELS: list[tuple[str, str]] = [
  17. ("mo", "Mo"),
  18. ("tu", "Di"),
  19. ("we", "Mi"),
  20. ("th", "Do"),
  21. ("fr", "Fr"),
  22. ("sa", "Sa"),
  23. ("su", "So"),
  24. ]
  25. def _resolve_field_type(raw: Any) -> str:
  26. """
  27. Loest den FieldType-Wert auf.
  28. FieldType kann als dict {"value": "text"} oder als String "text" ankommen.
  29. """
  30. if isinstance(raw, dict):
  31. return str(raw.get("value", "text"))
  32. return str(raw) if raw else "text"
  33. class ScheduleActionView(MainView):
  34. """F4 im Schedule-Kontext: Interaktive Aktionsverwaltung."""
  35. TITLE = "Actions"
  36. VIEW_ID = "sched_actions"
  37. DEFAULT_CSS = """
  38. ScheduleActionView #sched-act-buttons {
  39. height: 3;
  40. dock: top;
  41. margin: 1 0;
  42. }
  43. ScheduleActionView #sched-act-buttons Button {
  44. margin-right: 1;
  45. min-width: 14;
  46. }
  47. ScheduleActionView #sched-act-edit-section {
  48. height: auto;
  49. margin-top: 1;
  50. padding: 1;
  51. border: solid $accent;
  52. }
  53. ScheduleActionView #sched-act-edit-section .edit-header {
  54. text-style: bold;
  55. margin-bottom: 1;
  56. }
  57. ScheduleActionView #sched-act-fields {
  58. height: auto;
  59. margin-top: 1;
  60. }
  61. ScheduleActionView .field-label {
  62. height: 1;
  63. margin-top: 1;
  64. color: $text;
  65. }
  66. ScheduleActionView .weekday-row {
  67. height: 3;
  68. align: left middle;
  69. }
  70. ScheduleActionView .weekday-row Static {
  71. width: 4;
  72. margin-right: 1;
  73. }
  74. ScheduleActionView #sched-act-apply-btn {
  75. margin-top: 1;
  76. min-width: 16;
  77. }
  78. ScheduleActionView #sched-act-edit-error {
  79. height: 1;
  80. color: $error;
  81. }
  82. """
  83. def __init__(self, **kwargs) -> None:
  84. super().__init__(**kwargs)
  85. self._job_data: dict = {}
  86. self._form_meta: dict[str, dict] = {}
  87. self._actions: list[dict] = []
  88. self._editing_index: int | None = None
  89. self._selected_row: int | None = None
  90. # --- Oeffentliche API ---
  91. def set_job(self, job_data: dict) -> None:
  92. """Setzt die Job-Basisdaten."""
  93. self._job_data = job_data
  94. def set_form_meta(self, meta: dict[str, dict]) -> None:
  95. """Setzt die FormMeta-Definition fuer Aktionstypen."""
  96. self._form_meta = meta or {}
  97. def collect_data(self) -> list[dict]:
  98. """Gibt die aktuelle Aktionsliste zurueck."""
  99. return list(self._actions)
  100. # --- Compose ---
  101. def compose(self) -> ComposeResult:
  102. yield Static("[bold]Aktionen[/bold]", classes="section-header")
  103. yield DataTable(id="sched-act-table", cursor_type="row")
  104. yield Static("", id="sched-act-info")
  105. # Steuerungsbuttons
  106. with Horizontal(id="sched-act-buttons"):
  107. yield Button("Hinzufuegen", variant="primary", id="sched-act-add-btn")
  108. yield Button("Entfernen", variant="error", id="sched-act-remove-btn")
  109. yield Button("Hoch", variant="default", id="sched-act-up-btn")
  110. yield Button("Runter", variant="default", id="sched-act-down-btn")
  111. # Bearbeitungsbereich (initial versteckt)
  112. with Vertical(id="sched-act-edit-section"):
  113. yield Static("Aktion bearbeiten", classes="edit-header", id="sched-act-edit-title")
  114. yield Static("Typ:", classes="field-label")
  115. yield Select([], id="sched-act-type-select", allow_blank=True)
  116. yield Vertical(id="sched-act-fields")
  117. yield Static("", id="sched-act-edit-error")
  118. yield Button("Uebernehmen", variant="success", id="sched-act-apply-btn")
  119. # --- Lifecycle ---
  120. async def on_activate(self) -> None:
  121. """Initialisiert die Tabelle und laedt Daten vom Server."""
  122. table = self.query_one("#sched-act-table", DataTable)
  123. if not table.columns:
  124. table.add_columns("Reihenfolge", "Typ", "Name", "Aktiv")
  125. # Typ-Selector befuellen
  126. self._update_type_selector()
  127. # Bearbeitungsbereich initial verstecken
  128. edit_section = self.query_one("#sched-act-edit-section", Vertical)
  129. edit_section.display = False
  130. # Detail-Daten laden wenn Job-ID vorhanden
  131. if self._job_data.get("job_id"):
  132. await self._load_detail()
  133. else:
  134. self._update_table()
  135. async def _load_detail(self) -> None:
  136. """Laedt Aktionen vom Server."""
  137. if not self.connection or not self._job_data:
  138. return
  139. job_id = self._job_data.get("job_id", "")
  140. if not job_id:
  141. return
  142. try:
  143. detail = await self.connection.request_schedule_detail(job_id)
  144. except Exception:
  145. return
  146. if detail is None or detail.error:
  147. return
  148. # Aktionen uebernehmen
  149. self._actions = list(detail.actions) if detail.actions else []
  150. self._update_table()
  151. # --- Tabelle ---
  152. def _update_table(self) -> None:
  153. """Aktualisiert die Aktions-Tabelle und Info-Text."""
  154. table = self.query_one("#sched-act-table", DataTable)
  155. table.clear()
  156. info = self.query_one("#sched-act-info", Static)
  157. if not self._actions:
  158. info.update("[dim]Keine Aktionen konfiguriert[/dim]")
  159. return
  160. info.update(
  161. f"[bold]{len(self._actions)}[/bold] Aktion(en) "
  162. f"— werden sequenziell ausgefuehrt"
  163. )
  164. for i, action in enumerate(self._actions, 1):
  165. action_type = action.get("type", "Unbekannt")
  166. # Anzeigenamen aus FormMeta holen
  167. type_meta = self._form_meta.get(action_type, {})
  168. display_name = type_meta.get("display_name", action_type)
  169. name = action.get("name", "—")
  170. enabled = "Ja" if action.get("enabled", True) else "Nein"
  171. table.add_row(str(i), display_name, name, enabled)
  172. def _update_type_selector(self) -> None:
  173. """Befuellt den Typ-Selector mit verfuegbaren Aktionstypen."""
  174. try:
  175. selector = self.query_one("#sched-act-type-select", Select)
  176. except Exception:
  177. return
  178. options: list[tuple[str, str]] = []
  179. for type_key, meta in self._form_meta.items():
  180. display = meta.get("display_name", type_key)
  181. options.append((display, type_key))
  182. selector.set_options(options)
  183. # --- Bearbeitungsbereich ---
  184. def _show_edit_section(self, title: str = "Aktion bearbeiten") -> None:
  185. """Zeigt den Bearbeitungsbereich an."""
  186. edit_section = self.query_one("#sched-act-edit-section", Vertical)
  187. edit_section.display = True
  188. edit_title = self.query_one("#sched-act-edit-title", Static)
  189. edit_title.update(f"[bold]{title}[/bold]")
  190. # Fehler zuruecksetzen
  191. self.query_one("#sched-act-edit-error", Static).update("")
  192. def _hide_edit_section(self) -> None:
  193. """Versteckt den Bearbeitungsbereich."""
  194. edit_section = self.query_one("#sched-act-edit-section", Vertical)
  195. edit_section.display = False
  196. self._editing_index = None
  197. async def _render_fields(self, type_key: str, values: dict | None = None) -> None:
  198. """
  199. Rendert die dynamischen Formularfelder fuer einen Aktionstyp.
  200. Args:
  201. type_key: Schluessel des Aktionstyps aus FormMeta
  202. values: Optionale Vorbelegung der Felder
  203. """
  204. # Alten Container ersetzen um DuplicateIds zu vermeiden
  205. old_container = self.query_one("#sched-act-fields", Vertical)
  206. container = Vertical(id="sched-act-fields")
  207. await old_container.remove()
  208. edit_section = self.query_one("#sched-act-edit-section", Vertical)
  209. error_label = self.query_one("#sched-act-edit-error", Static)
  210. await edit_section.mount(container, before=error_label)
  211. if not type_key or type_key not in self._form_meta:
  212. return
  213. meta = self._form_meta[type_key]
  214. fields: list[dict] = meta.get("fields", [])
  215. values = values or {}
  216. for field_def in fields:
  217. field_name = field_def.get("name", "")
  218. field_label = field_def.get("label", field_name)
  219. field_type = _resolve_field_type(field_def.get("field_type", "text"))
  220. required = field_def.get("required", False)
  221. default = field_def.get("default")
  222. current_val = values.get(field_name, default)
  223. # Label mit Pflichtfeld-Markierung
  224. label_text = f"{field_label}{'*' if required else ''}:"
  225. label_widget = Static(label_text, classes="field-label")
  226. await container.mount(label_widget)
  227. # Feld-Widget je nach Typ erstellen
  228. if field_type == "weekdays":
  229. widget_id = f"sched-act-field-{field_name}"
  230. await self._create_weekday_switches(container, widget_id, current_val)
  231. else:
  232. widget = self._create_field_widget(
  233. field_name, field_type, field_def, current_val
  234. )
  235. if widget is not None:
  236. await container.mount(widget)
  237. def _create_field_widget(
  238. self,
  239. field_name: str,
  240. field_type: str,
  241. field_def: dict,
  242. current_val: Any,
  243. ) -> Input | Switch | TextArea | Select | Horizontal | None:
  244. """Erzeugt das passende Widget fuer einen Feldtyp."""
  245. widget_id = f"sched-act-field-{field_name}"
  246. placeholder = field_def.get("placeholder", "")
  247. if field_type == "text":
  248. str_val = str(current_val) if current_val is not None else ""
  249. return Input(
  250. value=str_val,
  251. placeholder=placeholder or field_name,
  252. id=widget_id,
  253. )
  254. elif field_type == "number":
  255. str_val = str(current_val) if current_val is not None else ""
  256. return Input(
  257. value=str_val,
  258. placeholder=placeholder or "Zahl eingeben",
  259. id=widget_id,
  260. )
  261. elif field_type == "checkbox":
  262. bool_val = bool(current_val) if current_val is not None else False
  263. return Switch(value=bool_val, id=widget_id)
  264. elif field_type == "textarea":
  265. str_val = str(current_val) if current_val is not None else ""
  266. return TextArea(text=str_val, id=widget_id)
  267. elif field_type == "datetime":
  268. str_val = str(current_val) if current_val is not None else ""
  269. return Input(
  270. value=str_val,
  271. placeholder=placeholder or "YYYY-MM-DD HH:MM:SS",
  272. id=widget_id,
  273. )
  274. elif field_type == "tags":
  275. # Kommaseparierte Tags
  276. if isinstance(current_val, list):
  277. str_val = ", ".join(str(v) for v in current_val)
  278. else:
  279. str_val = str(current_val) if current_val else ""
  280. return Input(
  281. value=str_val,
  282. placeholder=placeholder or "Werte kommasepariert",
  283. id=widget_id,
  284. )
  285. elif field_type == "weekdays":
  286. # Weekdays werden direkt in _render_fields behandelt
  287. return None
  288. elif field_type == "select":
  289. choices = field_def.get("choices", field_def.get("options", []))
  290. return self._create_select_field(widget_id, choices, current_val)
  291. # Fallback: Text-Input
  292. str_val = str(current_val) if current_val is not None else ""
  293. return Input(value=str_val, placeholder=field_name, id=widget_id)
  294. async def _create_weekday_switches(
  295. self, container: Vertical, widget_id: str, current_val: Any
  296. ) -> None:
  297. """Erzeugt 7 Wochentag-Switches als Zeilen im Container."""
  298. active_days: list[str] = []
  299. if isinstance(current_val, list):
  300. active_days = [str(d) for d in current_val]
  301. elif isinstance(current_val, dict):
  302. active_days = [k for k, v in current_val.items() if v]
  303. for day_key, day_label in _WEEKDAY_LABELS:
  304. is_on = day_key in active_days
  305. row = Horizontal(classes="weekday-row")
  306. await container.mount(row)
  307. await row.mount(Static(day_label))
  308. await row.mount(Switch(value=is_on, id=f"{widget_id}-{day_key}"))
  309. def _create_select_field(
  310. self, widget_id: str, choices: list, current_val: Any
  311. ) -> Select:
  312. """Erzeugt ein Select-Dropdown aus der choices-Liste."""
  313. options: list[tuple[str, str]] = []
  314. for choice in choices:
  315. if isinstance(choice, dict):
  316. val = str(choice.get("value", ""))
  317. label = choice.get("label", choice.get("display", val))
  318. options.append((str(label), val))
  319. elif isinstance(choice, (list, tuple)) and len(choice) >= 2:
  320. options.append((str(choice[1]), str(choice[0])))
  321. else:
  322. options.append((str(choice), str(choice)))
  323. current_str = str(current_val) if current_val is not None else ""
  324. return Select(
  325. options,
  326. value=current_str if any(v == current_str for _, v in options) else Select.BLANK,
  327. id=widget_id,
  328. allow_blank=True,
  329. )
  330. # --- Werte auslesen ---
  331. def _collect_field_values(self, type_key: str) -> dict[str, Any]:
  332. """Liest die aktuellen Werte aller Formularfelder aus."""
  333. result: dict[str, Any] = {"type": type_key}
  334. if type_key not in self._form_meta:
  335. return result
  336. meta = self._form_meta[type_key]
  337. fields: list[dict] = meta.get("fields", [])
  338. for field_def in fields:
  339. field_name = field_def.get("name", "")
  340. field_type = _resolve_field_type(field_def.get("field_type", "text"))
  341. widget_id = f"sched-act-field-{field_name}"
  342. try:
  343. value = self._read_field_value(widget_id, field_type, field_def)
  344. result[field_name] = value
  345. except Exception:
  346. # Widget nicht gefunden — Defaultwert verwenden
  347. result[field_name] = field_def.get("default")
  348. return result
  349. def _read_field_value(
  350. self, widget_id: str, field_type: str, field_def: dict
  351. ) -> Any:
  352. """Liest den Wert eines einzelnen Formularfeldes."""
  353. if field_type == "checkbox":
  354. widget = self.query_one(f"#{widget_id}", Switch)
  355. return widget.value
  356. elif field_type == "textarea":
  357. widget = self.query_one(f"#{widget_id}", TextArea)
  358. return widget.text
  359. elif field_type == "tags":
  360. widget = self.query_one(f"#{widget_id}", Input)
  361. raw = widget.value.strip()
  362. if not raw:
  363. return []
  364. return [t.strip() for t in raw.split(",") if t.strip()]
  365. elif field_type == "weekdays":
  366. active: list[str] = []
  367. for day_key, _ in _WEEKDAY_LABELS:
  368. try:
  369. sw = self.query_one(f"#{widget_id}-{day_key}", Switch)
  370. if sw.value:
  371. active.append(day_key)
  372. except Exception:
  373. pass
  374. return active
  375. elif field_type == "select":
  376. widget = self.query_one(f"#{widget_id}", Select)
  377. val = widget.value
  378. return str(val) if val != Select.BLANK else None
  379. elif field_type == "number":
  380. widget = self.query_one(f"#{widget_id}", Input)
  381. raw = widget.value.strip()
  382. if not raw:
  383. return None
  384. # Versuche int, dann float
  385. try:
  386. return int(raw)
  387. except ValueError:
  388. try:
  389. return float(raw)
  390. except ValueError:
  391. return raw
  392. else:
  393. # text, datetime und Fallback
  394. widget = self.query_one(f"#{widget_id}", Input)
  395. return widget.value
  396. # --- Event-Handler ---
  397. @on(DataTable.RowSelected, "#sched-act-table")
  398. async def _on_row_selected(self, event: DataTable.RowSelected) -> None:
  399. """Zeigt den Bearbeitungsbereich fuer die ausgewaehlte Aktion."""
  400. table = self.query_one("#sched-act-table", DataTable)
  401. row_index = event.cursor_row
  402. if row_index < 0 or row_index >= len(self._actions):
  403. return
  404. self._selected_row = row_index
  405. self._editing_index = row_index
  406. action = self._actions[row_index]
  407. action_type = action.get("type", "")
  408. self._show_edit_section(f"Aktion #{row_index + 1} bearbeiten")
  409. # Typ im Selector setzen
  410. try:
  411. selector = self.query_one("#sched-act-type-select", Select)
  412. if action_type and any(
  413. v == action_type for _, v in selector._options
  414. ):
  415. selector.value = action_type
  416. else:
  417. selector.clear()
  418. except Exception:
  419. pass
  420. # Felder rendern mit aktuellen Werten
  421. await self._render_fields(action_type, action)
  422. @on(Button.Pressed, "#sched-act-add-btn")
  423. async def _on_add_pressed(self, event: Button.Pressed) -> None:
  424. """Oeffnet den Bearbeitungsbereich fuer eine neue Aktion."""
  425. event.stop()
  426. self._editing_index = None
  427. self._show_edit_section("Neue Aktion hinzufuegen")
  428. # Typ-Selector zuruecksetzen
  429. try:
  430. selector = self.query_one("#sched-act-type-select", Select)
  431. selector.clear()
  432. except Exception:
  433. pass
  434. # Felder leeren — Container ersetzen
  435. old_container = self.query_one("#sched-act-fields", Vertical)
  436. new_container = Vertical(id="sched-act-fields")
  437. await old_container.remove()
  438. edit_section = self.query_one("#sched-act-edit-section", Vertical)
  439. error_label = self.query_one("#sched-act-edit-error", Static)
  440. await edit_section.mount(new_container, before=error_label)
  441. @on(Button.Pressed, "#sched-act-remove-btn")
  442. def _on_remove_pressed(self, event: Button.Pressed) -> None:
  443. """Entfernt die ausgewaehlte Aktion."""
  444. event.stop()
  445. table = self.query_one("#sched-act-table", DataTable)
  446. if not self._actions:
  447. return
  448. # Aktuelle Tabellenzeile ermitteln
  449. row_index = table.cursor_row
  450. if row_index < 0 or row_index >= len(self._actions):
  451. return
  452. self._actions.pop(row_index)
  453. self._selected_row = None
  454. self._hide_edit_section()
  455. self._update_table()
  456. @on(Button.Pressed, "#sched-act-up-btn")
  457. def _on_up_pressed(self, event: Button.Pressed) -> None:
  458. """Verschiebt die ausgewaehlte Aktion nach oben."""
  459. event.stop()
  460. table = self.query_one("#sched-act-table", DataTable)
  461. row_index = table.cursor_row
  462. if row_index <= 0 or row_index >= len(self._actions):
  463. return
  464. # Tauschen
  465. self._actions[row_index], self._actions[row_index - 1] = (
  466. self._actions[row_index - 1],
  467. self._actions[row_index],
  468. )
  469. self._update_table()
  470. # Cursor nachfuehren
  471. table.move_cursor(row=row_index - 1)
  472. @on(Button.Pressed, "#sched-act-down-btn")
  473. def _on_down_pressed(self, event: Button.Pressed) -> None:
  474. """Verschiebt die ausgewaehlte Aktion nach unten."""
  475. event.stop()
  476. table = self.query_one("#sched-act-table", DataTable)
  477. row_index = table.cursor_row
  478. if row_index < 0 or row_index >= len(self._actions) - 1:
  479. return
  480. # Tauschen
  481. self._actions[row_index], self._actions[row_index + 1] = (
  482. self._actions[row_index + 1],
  483. self._actions[row_index],
  484. )
  485. self._update_table()
  486. # Cursor nachfuehren
  487. table.move_cursor(row=row_index + 1)
  488. @on(Select.Changed, "#sched-act-type-select")
  489. async def _on_type_changed(self, event: Select.Changed) -> None:
  490. """Rendert die Formularfelder neu wenn sich der Aktionstyp aendert."""
  491. type_key = event.value
  492. if type_key == Select.BLANK or type_key is None:
  493. old_container = self.query_one("#sched-act-fields", Vertical)
  494. new_container = Vertical(id="sched-act-fields")
  495. await old_container.remove()
  496. edit_section = self.query_one("#sched-act-edit-section", Vertical)
  497. error_label = self.query_one("#sched-act-edit-error", Static)
  498. await edit_section.mount(new_container, before=error_label)
  499. return
  500. # Bei bestehendem Eintrag: Werte beibehalten wenn Typ gleich bleibt
  501. existing_values: dict | None = None
  502. if (
  503. self._editing_index is not None
  504. and 0 <= self._editing_index < len(self._actions)
  505. ):
  506. action = self._actions[self._editing_index]
  507. if action.get("type") == str(type_key):
  508. existing_values = action
  509. await self._render_fields(str(type_key), existing_values)
  510. @on(Button.Pressed, "#sched-act-apply-btn")
  511. def _on_apply_pressed(self, event: Button.Pressed) -> None:
  512. """Uebernimmt die Formularwerte in die Aktionsliste."""
  513. event.stop()
  514. error_label = self.query_one("#sched-act-edit-error", Static)
  515. # Typ auslesen
  516. try:
  517. selector = self.query_one("#sched-act-type-select", Select)
  518. type_key = selector.value
  519. except Exception:
  520. error_label.update("[red]Typ-Selector nicht gefunden[/red]")
  521. return
  522. if type_key == Select.BLANK or type_key is None:
  523. error_label.update("[red]Bitte einen Aktionstyp auswaehlen[/red]")
  524. return
  525. type_key_str = str(type_key)
  526. # Pflichtfelder pruefen
  527. validation_error = self._validate_required_fields(type_key_str)
  528. if validation_error:
  529. error_label.update(f"[red]{validation_error}[/red]")
  530. return
  531. # Werte sammeln
  532. action_data = self._collect_field_values(type_key_str)
  533. error_label.update("")
  534. if self._editing_index is not None and 0 <= self._editing_index < len(self._actions):
  535. # Bestehende Aktion aktualisieren
  536. self._actions[self._editing_index] = action_data
  537. else:
  538. # Neue Aktion hinzufuegen
  539. self._actions.append(action_data)
  540. self._hide_edit_section()
  541. self._update_table()
  542. def _validate_required_fields(self, type_key: str) -> str | None:
  543. """
  544. Prueft ob alle Pflichtfelder ausgefuellt sind.
  545. Returns:
  546. Fehlermeldung oder None wenn alles ok.
  547. """
  548. if type_key not in self._form_meta:
  549. return None
  550. meta = self._form_meta[type_key]
  551. fields: list[dict] = meta.get("fields", [])
  552. for field_def in fields:
  553. if not field_def.get("required", False):
  554. continue
  555. field_name = field_def.get("name", "")
  556. field_type = _resolve_field_type(field_def.get("field_type", "text"))
  557. widget_id = f"sched-act-field-{field_name}"
  558. try:
  559. value = self._read_field_value(widget_id, field_type, field_def)
  560. except Exception:
  561. label = field_def.get("label", field_name)
  562. return f"Pflichtfeld '{label}' nicht gefunden"
  563. # Leere Werte pruefen
  564. if value is None or value == "" or value == []:
  565. label = field_def.get("label", field_name)
  566. return f"Pflichtfeld '{label}' darf nicht leer sein"
  567. return None