schedule_trigger_view.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507
  1. # -*- coding: utf-8 -*-
  2. """
  3. Schedule SubView: Trigger (F2).
  4. Interaktive Bearbeitung der Trigger-Konfiguration eines Schedule-Jobs.
  5. Bietet eine Typ-Auswahl und dynamisch gerenderte Formularfelder
  6. basierend auf der FormMeta-Definition vom Server.
  7. """
  8. from __future__ import annotations
  9. from typing import Any
  10. from textual.app import ComposeResult
  11. from textual.containers import Vertical, Horizontal
  12. from textual.widgets import Static, Input, Switch, Select, TextArea
  13. from trixy_core.tui.views.base import MainView
  14. # Wochentage fuer den Weekdays-Feldtyp (Montag bis Sonntag)
  15. _WEEKDAY_LABELS: list[tuple[str, str]] = [
  16. ("mo", "Mo"),
  17. ("di", "Di"),
  18. ("mi", "Mi"),
  19. ("do", "Do"),
  20. ("fr", "Fr"),
  21. ("sa", "Sa"),
  22. ("so", "So"),
  23. ]
  24. # Sonderwert fuer "Kein Trigger"
  25. _NO_TRIGGER = "__none__"
  26. def _resolve_field_type(field_type: Any) -> str:
  27. """
  28. Loest den Feldtyp auf.
  29. FieldType kommt manchmal als dict mit 'value'-Key (nach Deserialisierung)
  30. und manchmal direkt als String.
  31. """
  32. if isinstance(field_type, dict):
  33. return str(field_type.get("value", "text"))
  34. return str(field_type) if field_type else "text"
  35. class ScheduleTriggerView(MainView):
  36. """F2 im Schedule-Kontext: Interaktive Trigger-Konfiguration."""
  37. TITLE = "Trigger"
  38. VIEW_ID = "sched_trigger"
  39. def __init__(self, **kwargs) -> None:
  40. super().__init__(**kwargs)
  41. self._job_data: dict = {}
  42. self._form_meta: dict[str, Any] = {}
  43. self._detail_data: object | None = None
  44. self._current_type: str | None = None
  45. self._field_widgets: dict[str, Any] = {}
  46. # ------------------------------------------------------------------
  47. # Oeffentliche API
  48. # ------------------------------------------------------------------
  49. def set_job(self, job_data: dict) -> None:
  50. """Setzt die Job-Basisdaten aus der Listenansicht."""
  51. self._job_data = job_data
  52. self._detail_data = None
  53. def set_form_meta(self, meta: dict[str, Any]) -> None:
  54. """Setzt die FormMeta-Definition fuer Trigger-Typen vom Server."""
  55. self._form_meta = meta or {}
  56. def collect_data(self) -> dict[str, Any]:
  57. """
  58. Sammelt die aktuellen Formularwerte und gibt ein Trigger-Dict zurueck.
  59. Gibt ein leeres Dict zurueck wenn kein Trigger ausgewaehlt ist.
  60. Format: {"type": "cron", "expression": "...", ...}
  61. """
  62. if not self._current_type or self._current_type == _NO_TRIGGER:
  63. return {}
  64. result: dict[str, Any] = {"type": self._current_type}
  65. meta_entry = self._form_meta.get(self._current_type, {})
  66. fields: list[dict] = meta_entry.get("fields", [])
  67. for field_def in fields:
  68. name = field_def.get("name", "")
  69. if not name:
  70. continue
  71. ft = _resolve_field_type(field_def.get("field_type"))
  72. value = self._read_field_value(name, ft, field_def)
  73. if value is not None:
  74. result[name] = value
  75. return result
  76. # ------------------------------------------------------------------
  77. # Compose & Lifecycle
  78. # ------------------------------------------------------------------
  79. def compose(self) -> ComposeResult:
  80. with Vertical(classes="section-box"):
  81. yield Static("[bold]Trigger-Typ[/bold]", classes="section-header")
  82. yield Select(
  83. [],
  84. id="sched-trigger-type",
  85. prompt="Trigger-Typ waehlen...",
  86. allow_blank=True,
  87. )
  88. with Vertical(id="sched-trigger-fields", classes="section-box"):
  89. yield Static(
  90. "[dim]Bitte Trigger-Typ auswaehlen[/dim]",
  91. id="sched-trigger-placeholder",
  92. )
  93. async def on_activate(self) -> None:
  94. """Laedt die Detail-Daten und befuellt Typ-Selektor und Felder."""
  95. self._populate_type_selector()
  96. await self._load_detail()
  97. async def refresh_data(self) -> None:
  98. """Kein periodisches Auto-Refresh bei editierbarer Ansicht."""
  99. pass
  100. # ------------------------------------------------------------------
  101. # Event-Handler
  102. # ------------------------------------------------------------------
  103. async def on_select_changed(self, event: Select.Changed) -> None:
  104. """Reagiert auf Aenderung des Trigger-Typ-Selektors."""
  105. if event.select.id != "sched-trigger-type":
  106. return
  107. selected = event.value
  108. if selected is Select.BLANK:
  109. self._current_type = None
  110. await self._rebuild_fields(None)
  111. elif str(selected) == _NO_TRIGGER:
  112. self._current_type = _NO_TRIGGER
  113. await self._rebuild_fields(None)
  114. else:
  115. self._current_type = str(selected)
  116. await self._rebuild_fields(self._current_type)
  117. # ------------------------------------------------------------------
  118. # Internes: Typ-Selektor befuellen
  119. # ------------------------------------------------------------------
  120. def _populate_type_selector(self) -> None:
  121. """Befuellt den Typ-Selektor mit verfuegbaren Trigger-Typen aus FormMeta."""
  122. selector = self.query_one("#sched-trigger-type", Select)
  123. options: list[tuple[str, str]] = [("Kein Trigger", _NO_TRIGGER)]
  124. for type_key, type_meta in self._form_meta.items():
  125. display_name = type_meta.get("display_name", type_key)
  126. options.append((display_name, type_key))
  127. selector.set_options(options)
  128. # Vorauswahl wenn Detail-Daten einen Trigger haben
  129. if self._detail_data and self._detail_data.trigger:
  130. trigger_type = self._detail_data.trigger.get("type", "")
  131. if trigger_type and trigger_type in self._form_meta:
  132. selector.value = trigger_type
  133. # ------------------------------------------------------------------
  134. # Internes: Detail-Daten laden
  135. # ------------------------------------------------------------------
  136. async def _load_detail(self) -> None:
  137. """Laedt detaillierte Job-Daten vom Server und setzt Formularwerte."""
  138. if not self.connection or not self._job_data:
  139. return
  140. job_id = self._job_data.get("job_id", "")
  141. if not job_id:
  142. return
  143. try:
  144. detail = await self.connection.request_schedule_detail(job_id)
  145. except Exception:
  146. return
  147. if detail is None or detail.error:
  148. return
  149. self._detail_data = detail
  150. await self._apply_detail_to_form()
  151. async def _apply_detail_to_form(self) -> None:
  152. """Wendet die geladenen Detail-Daten auf das Formular an."""
  153. d = self._detail_data
  154. if d is None:
  155. return
  156. selector = self.query_one("#sched-trigger-type", Select)
  157. if not d.trigger:
  158. selector.value = _NO_TRIGGER
  159. self._current_type = _NO_TRIGGER
  160. await self._rebuild_fields(None)
  161. return
  162. trigger = d.trigger
  163. trigger_type = trigger.get("type", "")
  164. if trigger_type and trigger_type in self._form_meta:
  165. selector.value = trigger_type
  166. self._current_type = trigger_type
  167. await self._rebuild_fields(trigger_type, prefill=trigger)
  168. else:
  169. selector.value = _NO_TRIGGER
  170. self._current_type = _NO_TRIGGER
  171. await self._rebuild_fields(None)
  172. # ------------------------------------------------------------------
  173. # Internes: Dynamisches Formular rendern
  174. # ------------------------------------------------------------------
  175. async def _rebuild_fields(
  176. self, trigger_type: str | None, prefill: dict[str, Any] | None = None
  177. ) -> None:
  178. """
  179. Entfernt alte Formular-Widgets und erstellt neue fuer den gewaehlten Typ.
  180. Args:
  181. trigger_type: Der gewaehlte Trigger-Typ oder None fuer keinen Trigger.
  182. prefill: Optionale Vorbelegung der Felder mit vorhandenen Werten.
  183. """
  184. # Alten Container komplett ersetzen um DuplicateIds zu vermeiden
  185. old_container = self.query_one("#sched-trigger-fields", Vertical)
  186. container = Vertical(id="sched-trigger-fields", classes="section-box")
  187. await old_container.remove()
  188. await self.mount(container)
  189. self._field_widgets.clear()
  190. if not trigger_type or trigger_type not in self._form_meta:
  191. await container.mount(
  192. Static(
  193. "[dim]Kein Trigger konfiguriert[/dim]",
  194. id="sched-trigger-placeholder",
  195. )
  196. )
  197. return
  198. meta_entry = self._form_meta[trigger_type]
  199. fields: list[dict] = meta_entry.get("fields", [])
  200. if not fields:
  201. await container.mount(
  202. Static(
  203. "[dim]Keine Felder fuer diesen Trigger-Typ[/dim]",
  204. id="sched-trigger-placeholder",
  205. )
  206. )
  207. return
  208. prefill = prefill or {}
  209. for field_def in fields:
  210. name = field_def.get("name", "")
  211. if not name:
  212. continue
  213. label = field_def.get("label", name)
  214. ft = _resolve_field_type(field_def.get("field_type"))
  215. help_text = field_def.get("help_text", "")
  216. required = field_def.get("required", False)
  217. default = field_def.get("default")
  218. prefill_value = prefill.get(name, default)
  219. # Label mit Pflichtfeld-Markierung
  220. label_text = f"[bold]{label}[/bold]"
  221. if required:
  222. label_text += " [red]*[/red]"
  223. if help_text:
  224. label_text += f" [dim]{help_text}[/dim]"
  225. widget_id = f"sched-tfield-{name}"
  226. if ft == "weekdays":
  227. await container.mount(Static(label_text))
  228. await self._mount_weekdays(container, widget_id, prefill_value)
  229. self._field_widgets[name] = (widget_id, ft)
  230. else:
  231. widget = self._create_field_widget(
  232. ft, widget_id, field_def, prefill_value
  233. )
  234. if widget is not None:
  235. await container.mount(Static(label_text))
  236. await container.mount(widget)
  237. self._field_widgets[name] = (widget_id, ft)
  238. def _create_field_widget(
  239. self,
  240. field_type: str,
  241. widget_id: str,
  242. field_def: dict,
  243. prefill_value: Any,
  244. ) -> Any:
  245. """
  246. Erstellt das passende Textual-Widget fuer einen Feldtyp.
  247. Args:
  248. field_type: Der Typ des Feldes (text, number, checkbox, etc.)
  249. widget_id: Die CSS-ID fuer das Widget.
  250. field_def: Die komplette Feld-Definition aus FormMeta.
  251. prefill_value: Vorbelegungswert oder None.
  252. Returns:
  253. Das erstellte Widget oder bei weekdays ein Horizontal-Container.
  254. """
  255. placeholder = field_def.get("placeholder", "")
  256. max_length = field_def.get("max_length")
  257. if field_type == "text":
  258. value = str(prefill_value) if prefill_value is not None else ""
  259. return Input(
  260. value=value,
  261. placeholder=placeholder or "",
  262. id=widget_id,
  263. max_length=max_length or 0,
  264. )
  265. if field_type == "number":
  266. value = str(prefill_value) if prefill_value is not None else ""
  267. is_float = field_def.get("is_float", False)
  268. ph = placeholder or ("Dezimalzahl" if is_float else "Ganzzahl")
  269. return Input(
  270. value=value,
  271. placeholder=ph,
  272. id=widget_id,
  273. type="number",
  274. )
  275. if field_type == "checkbox":
  276. checked = bool(prefill_value) if prefill_value is not None else False
  277. return Switch(value=checked, id=widget_id)
  278. if field_type == "textarea":
  279. value = str(prefill_value) if prefill_value is not None else ""
  280. lines = field_def.get("lines", 4)
  281. area = TextArea(id=widget_id)
  282. area.text = value
  283. area.styles.height = lines + 2
  284. return area
  285. if field_type == "datetime":
  286. value = str(prefill_value) if prefill_value is not None else ""
  287. ph = placeholder or "YYYY-MM-DD HH:MM:SS"
  288. return Input(
  289. value=value,
  290. placeholder=ph,
  291. id=widget_id,
  292. )
  293. if field_type == "tags":
  294. # Tags als kommaseparierter String
  295. if isinstance(prefill_value, list):
  296. value = ", ".join(str(t) for t in prefill_value)
  297. elif prefill_value is not None:
  298. value = str(prefill_value)
  299. else:
  300. value = ""
  301. return Input(
  302. value=value,
  303. placeholder=placeholder or "Tag1, Tag2, Tag3",
  304. id=widget_id,
  305. )
  306. if field_type == "weekdays":
  307. # Weekdays werden direkt in _rebuild_fields behandelt
  308. return None
  309. if field_type == "select":
  310. choices = field_def.get("choices", [])
  311. options: list[tuple[str, str]] = []
  312. for choice in choices:
  313. if isinstance(choice, dict):
  314. options.append((
  315. str(choice.get("label", choice.get("value", ""))),
  316. str(choice.get("value", "")),
  317. ))
  318. else:
  319. options.append((str(choice), str(choice)))
  320. sel = Select(options, id=widget_id, allow_blank=True, prompt="Auswahl...")
  321. if prefill_value is not None:
  322. sel.value = str(prefill_value)
  323. return sel
  324. # Fallback: einfaches Textfeld fuer unbekannte Typen
  325. value = str(prefill_value) if prefill_value is not None else ""
  326. return Input(
  327. value=value,
  328. placeholder=placeholder or "",
  329. id=widget_id,
  330. )
  331. async def _mount_weekdays(
  332. self, container: Vertical, widget_id: str, prefill_value: Any
  333. ) -> None:
  334. """
  335. Erstellt 7 beschriftete Switches fuer die Wochentag-Auswahl.
  336. Args:
  337. container: Der Container in den die Switches gemountet werden.
  338. widget_id: Basis-ID fuer die einzelnen Switches.
  339. prefill_value: Liste aktiver Tage oder Dict mit Tag-Booleans.
  340. """
  341. # Prefill normalisieren
  342. active_days: set[str] = set()
  343. if isinstance(prefill_value, list):
  344. active_days = {str(d).lower() for d in prefill_value}
  345. elif isinstance(prefill_value, dict):
  346. active_days = {k for k, v in prefill_value.items() if v}
  347. for day_key, day_label in _WEEKDAY_LABELS:
  348. checked = day_key in active_days
  349. row = Horizontal(classes="weekday-row")
  350. await container.mount(row)
  351. await row.mount(Static(f"[bold]{day_label}[/bold]"))
  352. await row.mount(Switch(
  353. value=checked,
  354. id=f"{widget_id}-{day_key}",
  355. ))
  356. # ------------------------------------------------------------------
  357. # Internes: Feldwerte auslesen
  358. # ------------------------------------------------------------------
  359. def _read_field_value(
  360. self, name: str, field_type: str, field_def: dict
  361. ) -> Any:
  362. """
  363. Liest den aktuellen Wert eines Formularfelds.
  364. Args:
  365. name: Der Feldname.
  366. field_type: Der Feldtyp.
  367. field_def: Die Feld-Definition.
  368. Returns:
  369. Den Feldwert im passenden Python-Typ.
  370. """
  371. widget_id = f"sched-tfield-{name}"
  372. try:
  373. if field_type in ("text", "datetime"):
  374. widget = self.query_one(f"#{widget_id}", Input)
  375. return widget.value
  376. if field_type == "number":
  377. widget = self.query_one(f"#{widget_id}", Input)
  378. raw = widget.value.strip()
  379. if not raw:
  380. return None
  381. is_float = field_def.get("is_float", False)
  382. return float(raw) if is_float else int(raw)
  383. if field_type == "checkbox":
  384. widget = self.query_one(f"#{widget_id}", Switch)
  385. return widget.value
  386. if field_type == "textarea":
  387. widget = self.query_one(f"#{widget_id}", TextArea)
  388. return widget.text
  389. if field_type == "tags":
  390. widget = self.query_one(f"#{widget_id}", Input)
  391. raw = widget.value.strip()
  392. if not raw:
  393. return []
  394. return [t.strip() for t in raw.split(",") if t.strip()]
  395. if field_type == "weekdays":
  396. active: list[str] = []
  397. for day_key, _ in _WEEKDAY_LABELS:
  398. try:
  399. sw = self.query_one(
  400. f"#{widget_id}-{day_key}", Switch
  401. )
  402. if sw.value:
  403. active.append(day_key)
  404. except Exception:
  405. continue
  406. return active
  407. if field_type == "select":
  408. widget = self.query_one(f"#{widget_id}", Select)
  409. if widget.value is Select.BLANK:
  410. return None
  411. return str(widget.value)
  412. # Fallback
  413. widget = self.query_one(f"#{widget_id}", Input)
  414. return widget.value
  415. except Exception:
  416. return None