schedule_condition_view.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592
  1. # -*- coding: utf-8 -*-
  2. """
  3. Schedule SubView: Conditions (F3).
  4. Interaktive Verwaltung der Bedingungen (Conditions) eines Schedule-Jobs.
  5. Ermoeglicht Hinzufuegen, Bearbeiten und Entfernen von Bedingungen
  6. mit dynamischen Formularfeldern je nach Bedingungstyp.
  7. """
  8. from __future__ import annotations
  9. from textual.app import ComposeResult
  10. from textual.containers import Vertical, Horizontal
  11. from textual.widgets import Static, Input, Switch, Button, Select, DataTable, TextArea
  12. from trixy_core.tui.views.base import MainView
  13. # Wochentags-Labels fuer den "weekdays"-Feldtyp
  14. _WEEKDAY_LABELS: list[str] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
  15. def _resolve_field_type(raw: str | dict) -> str:
  16. """Extrahiert den FieldType-String aus einem Wert oder Dict."""
  17. if isinstance(raw, dict):
  18. return str(raw.get("value", "text"))
  19. return str(raw)
  20. class ScheduleConditionView(MainView):
  21. """F3 im Schedule-Kontext: Bedingungen des Jobs (interaktiv)."""
  22. TITLE = "Conditions"
  23. VIEW_ID = "sched_conditions"
  24. def __init__(self, **kwargs) -> None:
  25. super().__init__(**kwargs)
  26. self._job_data: dict = {}
  27. self._form_meta: dict = {}
  28. self._conditions: list[dict] = []
  29. self._editing_index: int | None = None
  30. self._selected_row_index: int | None = None
  31. # ------------------------------------------------------------------
  32. # Oeffentliche API
  33. # ------------------------------------------------------------------
  34. def set_job(self, job_data: dict) -> None:
  35. """Setzt die Job-Basisdaten."""
  36. self._job_data = job_data
  37. self._conditions = []
  38. self._editing_index = None
  39. self._selected_row_index = None
  40. def set_form_meta(self, meta: dict) -> None:
  41. """Setzt die FormMeta-Definition fuer Condition-Typen."""
  42. self._form_meta = meta or {}
  43. def collect_data(self) -> list[dict]:
  44. """Gibt die aktuelle Liste aller Conditions zurueck."""
  45. return list(self._conditions)
  46. # ------------------------------------------------------------------
  47. # Compose
  48. # ------------------------------------------------------------------
  49. def compose(self) -> ComposeResult:
  50. yield Static("[bold]Bedingungen[/bold]", classes="section-header")
  51. yield DataTable(id="sched-cond-table", cursor_type="row")
  52. yield Static("", id="sched-cond-info")
  53. with Horizontal(classes="button-bar"):
  54. yield Button("Hinzufuegen", id="sched-cond-add", variant="success")
  55. yield Button("Entfernen", id="sched-cond-remove", variant="error")
  56. # Bearbeitungsbereich — wird dynamisch befuellt
  57. with Vertical(id="sched-cond-edit-section"):
  58. yield Static("", id="sched-cond-edit-title")
  59. yield Static("Typ:", classes="field-label")
  60. yield Select(
  61. [],
  62. id="sched-cond-type-select",
  63. prompt="Bedingungstyp waehlen...",
  64. )
  65. yield Vertical(id="sched-cond-fields")
  66. yield Button(
  67. "Uebernehmen",
  68. id="sched-cond-apply",
  69. variant="primary",
  70. )
  71. yield Static("", id="sched-cond-status")
  72. # ------------------------------------------------------------------
  73. # Lifecycle
  74. # ------------------------------------------------------------------
  75. async def on_activate(self) -> None:
  76. """Laedt die bestehenden Conditions beim Aktivieren."""
  77. table = self.query_one("#sched-cond-table", DataTable)
  78. if not table.columns:
  79. table.add_columns("Typ", "Name", "Aktiv")
  80. # Detail-Daten laden falls Job-ID vorhanden
  81. await self._load_detail()
  82. # Typ-Optionen aus FormMeta befuellen
  83. self._populate_type_select()
  84. # Bearbeitungsbereich initial verstecken
  85. self._hide_edit_section()
  86. async def _load_detail(self) -> None:
  87. """Laedt detaillierte Job-Daten vom Server und befuellt _conditions."""
  88. if not self.connection or not self._job_data:
  89. return
  90. job_id = self._job_data.get("job_id", "")
  91. if not job_id:
  92. return
  93. try:
  94. detail = await self.connection.request_schedule_detail(job_id)
  95. except Exception:
  96. return
  97. if detail is None or detail.error:
  98. return
  99. if detail.conditions:
  100. self._conditions = list(detail.conditions)
  101. self._refresh_table()
  102. # ------------------------------------------------------------------
  103. # Tabelle
  104. # ------------------------------------------------------------------
  105. def _refresh_table(self) -> None:
  106. """Aktualisiert die Conditions-Tabelle und die Info-Zeile."""
  107. table = self.query_one("#sched-cond-table", DataTable)
  108. table.clear()
  109. info = self.query_one("#sched-cond-info", Static)
  110. if not self._conditions:
  111. info.update(
  112. "[dim]Keine Bedingungen — Job wird immer ausgefuehrt "
  113. "wenn Trigger feuert[/dim]"
  114. )
  115. else:
  116. info.update(
  117. f"[bold]{len(self._conditions)}[/bold] Bedingung(en) "
  118. "— alle muessen erfuellt sein"
  119. )
  120. for cond in self._conditions:
  121. cond_type = cond.get("type", "Unbekannt")
  122. display_name = self._display_name_for_type(cond_type)
  123. name = cond.get("name", "—")
  124. enabled = "Ja" if cond.get("enabled", True) else "Nein"
  125. table.add_row(display_name, name, enabled)
  126. def _display_name_for_type(self, cond_type: str) -> str:
  127. """Gibt den Anzeigenamen eines Condition-Typs zurueck."""
  128. meta = self._form_meta.get(cond_type)
  129. if meta and isinstance(meta, dict):
  130. return meta.get("display_name", cond_type)
  131. return cond_type
  132. # ------------------------------------------------------------------
  133. # Bearbeitungsbereich
  134. # ------------------------------------------------------------------
  135. def _show_edit_section(self, title: str) -> None:
  136. """Macht den Bearbeitungsbereich sichtbar."""
  137. section = self.query_one("#sched-cond-edit-section")
  138. section.display = True
  139. title_widget = self.query_one("#sched-cond-edit-title", Static)
  140. title_widget.update(f"[bold]{title}[/bold]")
  141. def _hide_edit_section(self) -> None:
  142. """Versteckt den Bearbeitungsbereich."""
  143. section = self.query_one("#sched-cond-edit-section")
  144. section.display = False
  145. self._clear_dynamic_fields()
  146. def _populate_type_select(self) -> None:
  147. """Befuellt das Typ-Dropdown mit den verfuegbaren Condition-Typen."""
  148. select = self.query_one("#sched-cond-type-select", Select)
  149. options: list[tuple[str, str]] = []
  150. for type_key, type_meta in self._form_meta.items():
  151. if isinstance(type_meta, dict):
  152. label = type_meta.get("display_name", type_key)
  153. else:
  154. label = type_key
  155. options.append((label, type_key))
  156. select.set_options(options)
  157. def _clear_dynamic_fields(self) -> None:
  158. """Entfernt alle dynamisch erzeugten Formularfelder."""
  159. container = self.query_one("#sched-cond-fields", Vertical)
  160. container.remove_children()
  161. async def _render_fields_for_type(
  162. self, cond_type: str, values: dict | None = None
  163. ) -> None:
  164. """Erzeugt dynamische Formularfelder fuer den gewaehlten Typ."""
  165. # Alten Container ersetzen um DuplicateIds zu vermeiden
  166. old = self.query_one("#sched-cond-fields", Vertical)
  167. container = Vertical(id="sched-cond-fields")
  168. edit_section = self.query_one("#sched-cond-edit-section", Vertical)
  169. apply_btn = self.query_one("#sched-cond-apply", Button)
  170. await old.remove()
  171. await edit_section.mount(container, before=apply_btn)
  172. type_meta = self._form_meta.get(cond_type)
  173. if not type_meta or not isinstance(type_meta, dict):
  174. await container.mount(
  175. Static("[dim]Keine Felder fuer diesen Typ definiert[/dim]")
  176. )
  177. return
  178. fields = type_meta.get("fields", [])
  179. if values is None:
  180. values = {}
  181. for field_def in fields:
  182. field_name: str = field_def.get("name", "")
  183. field_label: str = field_def.get("label", field_name)
  184. field_type_raw = field_def.get("field_type", "text")
  185. field_type: str = _resolve_field_type(field_type_raw)
  186. default = field_def.get("default")
  187. current = values.get(field_name, default)
  188. required: bool = field_def.get("required", False)
  189. # Pflichtfeld-Markierung
  190. req_mark = " *" if required else ""
  191. widget_id = f"sched-cond-field-{field_name}"
  192. if field_type == "checkbox":
  193. val = bool(current) if current is not None else False
  194. await container.mount(
  195. Static(f"{field_label}{req_mark}:", classes="field-label")
  196. )
  197. await container.mount(Switch(value=val, id=widget_id))
  198. elif field_type == "textarea":
  199. text = str(current) if current is not None else ""
  200. await container.mount(
  201. Static(f"{field_label}{req_mark}:", classes="field-label")
  202. )
  203. await container.mount(TextArea(text, id=widget_id))
  204. elif field_type == "weekdays":
  205. await container.mount(
  206. Static(f"{field_label}{req_mark}:", classes="field-label")
  207. )
  208. # Aktuellen Wert als Liste parsen
  209. active_days: list[bool] = [False] * 7
  210. if isinstance(current, list) and len(current) == 7:
  211. active_days = [bool(d) for d in current]
  212. elif isinstance(current, str) and current:
  213. # Kommaseparierte Indizes
  214. for part in current.split(","):
  215. part = part.strip()
  216. if part.isdigit() and 0 <= int(part) < 7:
  217. active_days[int(part)] = True
  218. for i, day_label in enumerate(_WEEKDAY_LABELS):
  219. day_id = f"{widget_id}-{i}"
  220. row = Horizontal(classes="weekday-row")
  221. await container.mount(row)
  222. await row.mount(Static(f" {day_label}", classes="weekday-label"))
  223. await row.mount(Switch(value=active_days[i], id=day_id))
  224. elif field_type == "select":
  225. choices_raw = field_def.get("choices", [])
  226. options: list[tuple[str, str]] = []
  227. for c in choices_raw:
  228. if isinstance(c, dict):
  229. options.append(
  230. (c.get("label", c.get("value", "")), c.get("value", ""))
  231. )
  232. elif isinstance(c, (list, tuple)) and len(c) >= 2:
  233. options.append((str(c[0]), str(c[1])))
  234. else:
  235. options.append((str(c), str(c)))
  236. await container.mount(
  237. Static(f"{field_label}{req_mark}:", classes="field-label")
  238. )
  239. sel = Select(options, id=widget_id, prompt="Bitte waehlen...")
  240. if current is not None:
  241. sel.value = str(current)
  242. await container.mount(sel)
  243. elif field_type == "number":
  244. text = str(current) if current is not None else ""
  245. await container.mount(
  246. Static(f"{field_label}{req_mark}:", classes="field-label")
  247. )
  248. await container.mount(
  249. Input(
  250. value=text,
  251. placeholder=field_def.get("placeholder", ""),
  252. id=widget_id,
  253. )
  254. )
  255. elif field_type == "tags":
  256. # Kommaseparierte Tags
  257. if isinstance(current, list):
  258. text = ", ".join(str(t) for t in current)
  259. else:
  260. text = str(current) if current is not None else ""
  261. await container.mount(
  262. Static(
  263. f"{field_label}{req_mark} (Komma-getrennt):",
  264. classes="field-label",
  265. )
  266. )
  267. await container.mount(
  268. Input(
  269. value=text,
  270. placeholder=field_def.get("placeholder", "z.B. tag1, tag2"),
  271. id=widget_id,
  272. )
  273. )
  274. else:
  275. # text, datetime und Fallback
  276. text = str(current) if current is not None else ""
  277. placeholder = field_def.get("placeholder", "")
  278. if field_type == "datetime" and not placeholder:
  279. placeholder = "YYYY-MM-DD HH:MM:SS"
  280. await container.mount(
  281. Static(f"{field_label}{req_mark}:", classes="field-label")
  282. )
  283. await container.mount(
  284. Input(value=text, placeholder=placeholder, id=widget_id)
  285. )
  286. def _collect_field_values(self, cond_type: str) -> dict:
  287. """Sammelt die aktuellen Werte aller dynamischen Felder."""
  288. type_meta = self._form_meta.get(cond_type, {})
  289. fields = type_meta.get("fields", []) if isinstance(type_meta, dict) else []
  290. values: dict = {"type": cond_type}
  291. for field_def in fields:
  292. field_name: str = field_def.get("name", "")
  293. field_type_raw = field_def.get("field_type", "text")
  294. field_type: str = _resolve_field_type(field_type_raw)
  295. widget_id = f"sched-cond-field-{field_name}"
  296. try:
  297. if field_type == "checkbox":
  298. widget = self.query_one(f"#{widget_id}", Switch)
  299. values[field_name] = widget.value
  300. elif field_type == "textarea":
  301. widget = self.query_one(f"#{widget_id}", TextArea)
  302. values[field_name] = widget.text
  303. elif field_type == "weekdays":
  304. days: list[bool] = []
  305. for i in range(7):
  306. day_id = f"{widget_id}-{i}"
  307. sw = self.query_one(f"#{day_id}", Switch)
  308. days.append(sw.value)
  309. values[field_name] = days
  310. elif field_type == "select":
  311. widget = self.query_one(f"#{widget_id}", Select)
  312. val = widget.value
  313. values[field_name] = val if val != Select.BLANK else ""
  314. elif field_type == "number":
  315. widget = self.query_one(f"#{widget_id}", Input)
  316. raw = widget.value.strip()
  317. if raw:
  318. # Ganzzahl oder Fliesskomma
  319. try:
  320. values[field_name] = int(raw)
  321. except ValueError:
  322. try:
  323. values[field_name] = float(raw)
  324. except ValueError:
  325. values[field_name] = raw
  326. else:
  327. values[field_name] = None
  328. elif field_type == "tags":
  329. widget = self.query_one(f"#{widget_id}", Input)
  330. raw = widget.value.strip()
  331. if raw:
  332. values[field_name] = [
  333. t.strip() for t in raw.split(",") if t.strip()
  334. ]
  335. else:
  336. values[field_name] = []
  337. else:
  338. # text, datetime, Fallback
  339. widget = self.query_one(f"#{widget_id}", Input)
  340. values[field_name] = widget.value
  341. except Exception:
  342. # Widget nicht gefunden — Standardwert
  343. values[field_name] = field_def.get("default")
  344. return values
  345. # ------------------------------------------------------------------
  346. # Event-Handler: Buttons
  347. # ------------------------------------------------------------------
  348. async def on_button_pressed(self, event: Button.Pressed) -> None:
  349. """Verarbeitet Button-Klicks."""
  350. button_id = event.button.id
  351. if button_id == "sched-cond-add":
  352. await self._handle_add()
  353. elif button_id == "sched-cond-remove":
  354. self._handle_remove()
  355. elif button_id == "sched-cond-apply":
  356. await self._handle_apply()
  357. async def _handle_add(self) -> None:
  358. """Oeffnet den Bearbeitungsbereich fuer eine neue Bedingung."""
  359. self._editing_index = None
  360. self._show_edit_section("Neue Bedingung hinzufuegen")
  361. # Typ-Selektor zuruecksetzen
  362. select = self.query_one("#sched-cond-type-select", Select)
  363. select.clear()
  364. # Felder leeren
  365. self._clear_dynamic_fields()
  366. self._set_status("")
  367. def _handle_remove(self) -> None:
  368. """Entfernt die aktuell selektierte Bedingung."""
  369. if self._selected_row_index is None:
  370. self._set_status("Keine Bedingung ausgewaehlt", error=True)
  371. return
  372. idx = self._selected_row_index
  373. if 0 <= idx < len(self._conditions):
  374. removed = self._conditions.pop(idx)
  375. removed_type = removed.get("type", "Unbekannt")
  376. self._selected_row_index = None
  377. # Falls gerade diese Bedingung bearbeitet wird, Bereich schliessen
  378. if self._editing_index == idx:
  379. self._hide_edit_section()
  380. self._editing_index = None
  381. elif self._editing_index is not None and self._editing_index > idx:
  382. # Index nach unten korrigieren
  383. self._editing_index -= 1
  384. self._refresh_table()
  385. self._set_status(
  386. f"Bedingung '{self._display_name_for_type(removed_type)}' entfernt"
  387. )
  388. else:
  389. self._set_status("Ungueltiger Index", error=True)
  390. async def _handle_apply(self) -> None:
  391. """Uebernimmt die Werte aus dem Bearbeitungsbereich."""
  392. select = self.query_one("#sched-cond-type-select", Select)
  393. cond_type = select.value
  394. if cond_type == Select.BLANK or not cond_type:
  395. self._set_status("Bitte einen Bedingungstyp waehlen", error=True)
  396. return
  397. values = self._collect_field_values(str(cond_type))
  398. if self._editing_index is not None:
  399. # Bestehende Bedingung aktualisieren
  400. if 0 <= self._editing_index < len(self._conditions):
  401. self._conditions[self._editing_index] = values
  402. self._set_status(
  403. f"Bedingung {self._editing_index + 1} aktualisiert"
  404. )
  405. else:
  406. self._set_status("Ungueltiger Index", error=True)
  407. return
  408. else:
  409. # Neue Bedingung anfuegen
  410. self._conditions.append(values)
  411. self._set_status(
  412. f"Bedingung '{self._display_name_for_type(str(cond_type))}' "
  413. "hinzugefuegt"
  414. )
  415. self._refresh_table()
  416. self._hide_edit_section()
  417. self._editing_index = None
  418. # ------------------------------------------------------------------
  419. # Event-Handler: Tabellenzeile selektiert
  420. # ------------------------------------------------------------------
  421. async def on_data_table_row_selected(
  422. self, event: DataTable.RowSelected
  423. ) -> None:
  424. """Wird aufgerufen wenn eine Tabellenzeile angeklickt wird."""
  425. table = self.query_one("#sched-cond-table", DataTable)
  426. # Zeilenindex ermitteln
  427. row_index: int | None = None
  428. for i, row_key in enumerate(table.rows):
  429. if row_key == event.row_key:
  430. row_index = i
  431. break
  432. if row_index is None or row_index >= len(self._conditions):
  433. return
  434. self._selected_row_index = row_index
  435. self._editing_index = row_index
  436. cond = self._conditions[row_index]
  437. cond_type = cond.get("type", "")
  438. self._show_edit_section(
  439. f"Bedingung {row_index + 1} bearbeiten"
  440. )
  441. # Typ im Selektor setzen
  442. select = self.query_one("#sched-cond-type-select", Select)
  443. if cond_type and cond_type in self._form_meta:
  444. select.value = cond_type
  445. else:
  446. select.clear()
  447. # Felder rendern mit bestehenden Werten
  448. if cond_type:
  449. await self._render_fields_for_type(cond_type, cond)
  450. self._set_status(f"Bedingung {row_index + 1} zum Bearbeiten geladen")
  451. # ------------------------------------------------------------------
  452. # Event-Handler: Typ-Auswahl geaendert
  453. # ------------------------------------------------------------------
  454. async def on_select_changed(self, event: Select.Changed) -> None:
  455. """Reagiert auf Aenderungen im Typ-Selektor."""
  456. if event.select.id != "sched-cond-type-select":
  457. return
  458. cond_type = event.value
  459. if cond_type == Select.BLANK or not cond_type:
  460. old = self.query_one("#sched-cond-fields", Vertical)
  461. new_container = Vertical(id="sched-cond-fields")
  462. edit_section = self.query_one("#sched-cond-edit-section", Vertical)
  463. apply_btn = self.query_one("#sched-cond-apply", Button)
  464. await old.remove()
  465. await edit_section.mount(new_container, before=apply_btn)
  466. return
  467. # Bestehende Werte laden falls Bearbeitung
  468. existing_values: dict | None = None
  469. if (
  470. self._editing_index is not None
  471. and 0 <= self._editing_index < len(self._conditions)
  472. ):
  473. existing = self._conditions[self._editing_index]
  474. if existing.get("type") == str(cond_type):
  475. existing_values = existing
  476. await self._render_fields_for_type(str(cond_type), existing_values)
  477. # ------------------------------------------------------------------
  478. # Status
  479. # ------------------------------------------------------------------
  480. def _set_status(self, text: str, error: bool = False) -> None:
  481. """Setzt die Statuszeile."""
  482. status = self.query_one("#sched-cond-status", Static)
  483. if error:
  484. status.update(f"[red]{text}[/red]")
  485. elif text:
  486. status.update(f"[green]{text}[/green]")
  487. else:
  488. status.update("")