# -*- coding: utf-8 -*- """ Schedule SubView: Actions (F4). Interaktive Verwaltung der Aktionen eines Schedule-Jobs. Ermoeglicht Hinzufuegen, Entfernen, Bearbeiten und Umsortieren von Aktionen mit dynamischen Formularfeldern je nach Aktionstyp. """ from __future__ import annotations from typing import Any from textual import on from textual.app import ComposeResult from textual.containers import Vertical, Horizontal from textual.widgets import Static, Input, Switch, Button, Select, DataTable, TextArea from trixy_core.tui.views.base import MainView # Wochentag-Labels fuer Weekday-Switches _WEEKDAY_LABELS: list[tuple[str, str]] = [ ("mo", "Mo"), ("tu", "Di"), ("we", "Mi"), ("th", "Do"), ("fr", "Fr"), ("sa", "Sa"), ("su", "So"), ] def _resolve_field_type(raw: Any) -> str: """ Loest den FieldType-Wert auf. FieldType kann als dict {"value": "text"} oder als String "text" ankommen. """ if isinstance(raw, dict): return str(raw.get("value", "text")) return str(raw) if raw else "text" class ScheduleActionView(MainView): """F4 im Schedule-Kontext: Interaktive Aktionsverwaltung.""" TITLE = "Actions" VIEW_ID = "sched_actions" DEFAULT_CSS = """ ScheduleActionView #sched-act-buttons { height: 3; dock: top; margin: 1 0; } ScheduleActionView #sched-act-buttons Button { margin-right: 1; min-width: 14; } ScheduleActionView #sched-act-edit-section { height: auto; margin-top: 1; padding: 1; border: solid $accent; } ScheduleActionView #sched-act-edit-section .edit-header { text-style: bold; margin-bottom: 1; } ScheduleActionView #sched-act-fields { height: auto; margin-top: 1; } ScheduleActionView .field-label { height: 1; margin-top: 1; color: $text; } ScheduleActionView .weekday-row { height: 3; align: left middle; } ScheduleActionView .weekday-row Static { width: 4; margin-right: 1; } ScheduleActionView #sched-act-apply-btn { margin-top: 1; min-width: 16; } ScheduleActionView #sched-act-edit-error { height: 1; color: $error; } """ def __init__(self, **kwargs) -> None: super().__init__(**kwargs) self._job_data: dict = {} self._form_meta: dict[str, dict] = {} self._actions: list[dict] = [] self._editing_index: int | None = None self._selected_row: int | None = None # --- Oeffentliche API --- def set_job(self, job_data: dict) -> None: """Setzt die Job-Basisdaten.""" self._job_data = job_data def set_form_meta(self, meta: dict[str, dict]) -> None: """Setzt die FormMeta-Definition fuer Aktionstypen.""" self._form_meta = meta or {} def collect_data(self) -> list[dict]: """Gibt die aktuelle Aktionsliste zurueck.""" return list(self._actions) # --- Compose --- def compose(self) -> ComposeResult: yield Static("[bold]Aktionen[/bold]", classes="section-header") yield DataTable(id="sched-act-table", cursor_type="row") yield Static("", id="sched-act-info") # Steuerungsbuttons with Horizontal(id="sched-act-buttons"): yield Button("Hinzufuegen", variant="primary", id="sched-act-add-btn") yield Button("Entfernen", variant="error", id="sched-act-remove-btn") yield Button("Hoch", variant="default", id="sched-act-up-btn") yield Button("Runter", variant="default", id="sched-act-down-btn") # Bearbeitungsbereich (initial versteckt) with Vertical(id="sched-act-edit-section"): yield Static("Aktion bearbeiten", classes="edit-header", id="sched-act-edit-title") yield Static("Typ:", classes="field-label") yield Select([], id="sched-act-type-select", allow_blank=True) yield Vertical(id="sched-act-fields") yield Static("", id="sched-act-edit-error") yield Button("Uebernehmen", variant="success", id="sched-act-apply-btn") # --- Lifecycle --- async def on_activate(self) -> None: """Initialisiert die Tabelle und laedt Daten vom Server.""" table = self.query_one("#sched-act-table", DataTable) if not table.columns: table.add_columns("Reihenfolge", "Typ", "Name", "Aktiv") # Typ-Selector befuellen self._update_type_selector() # Bearbeitungsbereich initial verstecken edit_section = self.query_one("#sched-act-edit-section", Vertical) edit_section.display = False # Detail-Daten laden wenn Job-ID vorhanden if self._job_data.get("job_id"): await self._load_detail() else: self._update_table() async def _load_detail(self) -> None: """Laedt Aktionen vom Server.""" if not self.connection or not self._job_data: return job_id = self._job_data.get("job_id", "") if not job_id: return try: detail = await self.connection.request_schedule_detail(job_id) except Exception: return if detail is None or detail.error: return # Aktionen uebernehmen self._actions = list(detail.actions) if detail.actions else [] self._update_table() # --- Tabelle --- def _update_table(self) -> None: """Aktualisiert die Aktions-Tabelle und Info-Text.""" table = self.query_one("#sched-act-table", DataTable) table.clear() info = self.query_one("#sched-act-info", Static) if not self._actions: info.update("[dim]Keine Aktionen konfiguriert[/dim]") return info.update( f"[bold]{len(self._actions)}[/bold] Aktion(en) " f"— werden sequenziell ausgefuehrt" ) for i, action in enumerate(self._actions, 1): action_type = action.get("type", "Unbekannt") # Anzeigenamen aus FormMeta holen type_meta = self._form_meta.get(action_type, {}) display_name = type_meta.get("display_name", action_type) name = action.get("name", "—") enabled = "Ja" if action.get("enabled", True) else "Nein" table.add_row(str(i), display_name, name, enabled) def _update_type_selector(self) -> None: """Befuellt den Typ-Selector mit verfuegbaren Aktionstypen.""" try: selector = self.query_one("#sched-act-type-select", Select) except Exception: return options: list[tuple[str, str]] = [] for type_key, meta in self._form_meta.items(): display = meta.get("display_name", type_key) options.append((display, type_key)) selector.set_options(options) # --- Bearbeitungsbereich --- def _show_edit_section(self, title: str = "Aktion bearbeiten") -> None: """Zeigt den Bearbeitungsbereich an.""" edit_section = self.query_one("#sched-act-edit-section", Vertical) edit_section.display = True edit_title = self.query_one("#sched-act-edit-title", Static) edit_title.update(f"[bold]{title}[/bold]") # Fehler zuruecksetzen self.query_one("#sched-act-edit-error", Static).update("") def _hide_edit_section(self) -> None: """Versteckt den Bearbeitungsbereich.""" edit_section = self.query_one("#sched-act-edit-section", Vertical) edit_section.display = False self._editing_index = None async def _render_fields(self, type_key: str, values: dict | None = None) -> None: """ Rendert die dynamischen Formularfelder fuer einen Aktionstyp. Args: type_key: Schluessel des Aktionstyps aus FormMeta values: Optionale Vorbelegung der Felder """ # Alten Container ersetzen um DuplicateIds zu vermeiden old_container = self.query_one("#sched-act-fields", Vertical) container = Vertical(id="sched-act-fields") await old_container.remove() edit_section = self.query_one("#sched-act-edit-section", Vertical) error_label = self.query_one("#sched-act-edit-error", Static) await edit_section.mount(container, before=error_label) if not type_key or type_key not in self._form_meta: return meta = self._form_meta[type_key] fields: list[dict] = meta.get("fields", []) values = values or {} for field_def in fields: field_name = field_def.get("name", "") field_label = field_def.get("label", field_name) field_type = _resolve_field_type(field_def.get("field_type", "text")) required = field_def.get("required", False) default = field_def.get("default") current_val = values.get(field_name, default) # Label mit Pflichtfeld-Markierung label_text = f"{field_label}{'*' if required else ''}:" label_widget = Static(label_text, classes="field-label") await container.mount(label_widget) # Feld-Widget je nach Typ erstellen if field_type == "weekdays": widget_id = f"sched-act-field-{field_name}" await self._create_weekday_switches(container, widget_id, current_val) else: widget = self._create_field_widget( field_name, field_type, field_def, current_val ) if widget is not None: await container.mount(widget) def _create_field_widget( self, field_name: str, field_type: str, field_def: dict, current_val: Any, ) -> Input | Switch | TextArea | Select | Horizontal | None: """Erzeugt das passende Widget fuer einen Feldtyp.""" widget_id = f"sched-act-field-{field_name}" placeholder = field_def.get("placeholder", "") if field_type == "text": str_val = str(current_val) if current_val is not None else "" return Input( value=str_val, placeholder=placeholder or field_name, id=widget_id, ) elif field_type == "number": str_val = str(current_val) if current_val is not None else "" return Input( value=str_val, placeholder=placeholder or "Zahl eingeben", id=widget_id, ) elif field_type == "checkbox": bool_val = bool(current_val) if current_val is not None else False return Switch(value=bool_val, id=widget_id) elif field_type == "textarea": str_val = str(current_val) if current_val is not None else "" return TextArea(text=str_val, id=widget_id) elif field_type == "datetime": str_val = str(current_val) if current_val is not None else "" return Input( value=str_val, placeholder=placeholder or "YYYY-MM-DD HH:MM:SS", id=widget_id, ) elif field_type == "tags": # Kommaseparierte Tags if isinstance(current_val, list): str_val = ", ".join(str(v) for v in current_val) else: str_val = str(current_val) if current_val else "" return Input( value=str_val, placeholder=placeholder or "Werte kommasepariert", id=widget_id, ) elif field_type == "weekdays": # Weekdays werden direkt in _render_fields behandelt return None elif field_type == "select": choices = field_def.get("choices", field_def.get("options", [])) return self._create_select_field(widget_id, choices, current_val) # Fallback: Text-Input str_val = str(current_val) if current_val is not None else "" return Input(value=str_val, placeholder=field_name, id=widget_id) async def _create_weekday_switches( self, container: Vertical, widget_id: str, current_val: Any ) -> None: """Erzeugt 7 Wochentag-Switches als Zeilen im Container.""" active_days: list[str] = [] if isinstance(current_val, list): active_days = [str(d) for d in current_val] elif isinstance(current_val, dict): active_days = [k for k, v in current_val.items() if v] for day_key, day_label in _WEEKDAY_LABELS: is_on = day_key in active_days row = Horizontal(classes="weekday-row") await container.mount(row) await row.mount(Static(day_label)) await row.mount(Switch(value=is_on, id=f"{widget_id}-{day_key}")) def _create_select_field( self, widget_id: str, choices: list, current_val: Any ) -> Select: """Erzeugt ein Select-Dropdown aus der choices-Liste.""" options: list[tuple[str, str]] = [] for choice in choices: if isinstance(choice, dict): val = str(choice.get("value", "")) label = choice.get("label", choice.get("display", val)) options.append((str(label), val)) elif isinstance(choice, (list, tuple)) and len(choice) >= 2: options.append((str(choice[1]), str(choice[0]))) else: options.append((str(choice), str(choice))) current_str = str(current_val) if current_val is not None else "" return Select( options, value=current_str if any(v == current_str for _, v in options) else Select.BLANK, id=widget_id, allow_blank=True, ) # --- Werte auslesen --- def _collect_field_values(self, type_key: str) -> dict[str, Any]: """Liest die aktuellen Werte aller Formularfelder aus.""" result: dict[str, Any] = {"type": type_key} if type_key not in self._form_meta: return result meta = self._form_meta[type_key] fields: list[dict] = meta.get("fields", []) for field_def in fields: field_name = field_def.get("name", "") field_type = _resolve_field_type(field_def.get("field_type", "text")) widget_id = f"sched-act-field-{field_name}" try: value = self._read_field_value(widget_id, field_type, field_def) result[field_name] = value except Exception: # Widget nicht gefunden — Defaultwert verwenden result[field_name] = field_def.get("default") return result def _read_field_value( self, widget_id: str, field_type: str, field_def: dict ) -> Any: """Liest den Wert eines einzelnen Formularfeldes.""" if field_type == "checkbox": widget = self.query_one(f"#{widget_id}", Switch) return widget.value elif field_type == "textarea": widget = self.query_one(f"#{widget_id}", TextArea) return widget.text elif field_type == "tags": widget = self.query_one(f"#{widget_id}", Input) raw = widget.value.strip() if not raw: return [] return [t.strip() for t in raw.split(",") if t.strip()] elif field_type == "weekdays": active: list[str] = [] for day_key, _ in _WEEKDAY_LABELS: try: sw = self.query_one(f"#{widget_id}-{day_key}", Switch) if sw.value: active.append(day_key) except Exception: pass return active elif field_type == "select": widget = self.query_one(f"#{widget_id}", Select) val = widget.value return str(val) if val != Select.BLANK else None elif field_type == "number": widget = self.query_one(f"#{widget_id}", Input) raw = widget.value.strip() if not raw: return None # Versuche int, dann float try: return int(raw) except ValueError: try: return float(raw) except ValueError: return raw else: # text, datetime und Fallback widget = self.query_one(f"#{widget_id}", Input) return widget.value # --- Event-Handler --- @on(DataTable.RowSelected, "#sched-act-table") async def _on_row_selected(self, event: DataTable.RowSelected) -> None: """Zeigt den Bearbeitungsbereich fuer die ausgewaehlte Aktion.""" table = self.query_one("#sched-act-table", DataTable) row_index = event.cursor_row if row_index < 0 or row_index >= len(self._actions): return self._selected_row = row_index self._editing_index = row_index action = self._actions[row_index] action_type = action.get("type", "") self._show_edit_section(f"Aktion #{row_index + 1} bearbeiten") # Typ im Selector setzen try: selector = self.query_one("#sched-act-type-select", Select) if action_type and any( v == action_type for _, v in selector._options ): selector.value = action_type else: selector.clear() except Exception: pass # Felder rendern mit aktuellen Werten await self._render_fields(action_type, action) @on(Button.Pressed, "#sched-act-add-btn") async def _on_add_pressed(self, event: Button.Pressed) -> None: """Oeffnet den Bearbeitungsbereich fuer eine neue Aktion.""" event.stop() self._editing_index = None self._show_edit_section("Neue Aktion hinzufuegen") # Typ-Selector zuruecksetzen try: selector = self.query_one("#sched-act-type-select", Select) selector.clear() except Exception: pass # Felder leeren — Container ersetzen old_container = self.query_one("#sched-act-fields", Vertical) new_container = Vertical(id="sched-act-fields") await old_container.remove() edit_section = self.query_one("#sched-act-edit-section", Vertical) error_label = self.query_one("#sched-act-edit-error", Static) await edit_section.mount(new_container, before=error_label) @on(Button.Pressed, "#sched-act-remove-btn") def _on_remove_pressed(self, event: Button.Pressed) -> None: """Entfernt die ausgewaehlte Aktion.""" event.stop() table = self.query_one("#sched-act-table", DataTable) if not self._actions: return # Aktuelle Tabellenzeile ermitteln row_index = table.cursor_row if row_index < 0 or row_index >= len(self._actions): return self._actions.pop(row_index) self._selected_row = None self._hide_edit_section() self._update_table() @on(Button.Pressed, "#sched-act-up-btn") def _on_up_pressed(self, event: Button.Pressed) -> None: """Verschiebt die ausgewaehlte Aktion nach oben.""" event.stop() table = self.query_one("#sched-act-table", DataTable) row_index = table.cursor_row if row_index <= 0 or row_index >= len(self._actions): return # Tauschen self._actions[row_index], self._actions[row_index - 1] = ( self._actions[row_index - 1], self._actions[row_index], ) self._update_table() # Cursor nachfuehren table.move_cursor(row=row_index - 1) @on(Button.Pressed, "#sched-act-down-btn") def _on_down_pressed(self, event: Button.Pressed) -> None: """Verschiebt die ausgewaehlte Aktion nach unten.""" event.stop() table = self.query_one("#sched-act-table", DataTable) row_index = table.cursor_row if row_index < 0 or row_index >= len(self._actions) - 1: return # Tauschen self._actions[row_index], self._actions[row_index + 1] = ( self._actions[row_index + 1], self._actions[row_index], ) self._update_table() # Cursor nachfuehren table.move_cursor(row=row_index + 1) @on(Select.Changed, "#sched-act-type-select") async def _on_type_changed(self, event: Select.Changed) -> None: """Rendert die Formularfelder neu wenn sich der Aktionstyp aendert.""" type_key = event.value if type_key == Select.BLANK or type_key is None: old_container = self.query_one("#sched-act-fields", Vertical) new_container = Vertical(id="sched-act-fields") await old_container.remove() edit_section = self.query_one("#sched-act-edit-section", Vertical) error_label = self.query_one("#sched-act-edit-error", Static) await edit_section.mount(new_container, before=error_label) return # Bei bestehendem Eintrag: Werte beibehalten wenn Typ gleich bleibt existing_values: dict | None = None if ( self._editing_index is not None and 0 <= self._editing_index < len(self._actions) ): action = self._actions[self._editing_index] if action.get("type") == str(type_key): existing_values = action await self._render_fields(str(type_key), existing_values) @on(Button.Pressed, "#sched-act-apply-btn") def _on_apply_pressed(self, event: Button.Pressed) -> None: """Uebernimmt die Formularwerte in die Aktionsliste.""" event.stop() error_label = self.query_one("#sched-act-edit-error", Static) # Typ auslesen try: selector = self.query_one("#sched-act-type-select", Select) type_key = selector.value except Exception: error_label.update("[red]Typ-Selector nicht gefunden[/red]") return if type_key == Select.BLANK or type_key is None: error_label.update("[red]Bitte einen Aktionstyp auswaehlen[/red]") return type_key_str = str(type_key) # Pflichtfelder pruefen validation_error = self._validate_required_fields(type_key_str) if validation_error: error_label.update(f"[red]{validation_error}[/red]") return # Werte sammeln action_data = self._collect_field_values(type_key_str) error_label.update("") if self._editing_index is not None and 0 <= self._editing_index < len(self._actions): # Bestehende Aktion aktualisieren self._actions[self._editing_index] = action_data else: # Neue Aktion hinzufuegen self._actions.append(action_data) self._hide_edit_section() self._update_table() def _validate_required_fields(self, type_key: str) -> str | None: """ Prueft ob alle Pflichtfelder ausgefuellt sind. Returns: Fehlermeldung oder None wenn alles ok. """ if type_key not in self._form_meta: return None meta = self._form_meta[type_key] fields: list[dict] = meta.get("fields", []) for field_def in fields: if not field_def.get("required", False): continue field_name = field_def.get("name", "") field_type = _resolve_field_type(field_def.get("field_type", "text")) widget_id = f"sched-act-field-{field_name}" try: value = self._read_field_value(widget_id, field_type, field_def) except Exception: label = field_def.get("label", field_name) return f"Pflichtfeld '{label}' nicht gefunden" # Leere Werte pruefen if value is None or value == "" or value == []: label = field_def.get("label", field_name) return f"Pflichtfeld '{label}' darf nicht leer sein" return None