| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687 |
- # -*- 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
|