""" GUI Window for creating, editing, and deleting export profiles. """ import tkinter as tk from tkinter import ttk, simpledialog, messagebox import dataclasses import inspect import copy from typing import List, Type from ..core import data_structures as ds from ..core.export_profiles import ExportProfile, ExportField from ..utils import logger log = logger.get_logger(__name__) # --- Constants --- # Base classes from which to start introspection. # We add DataBatch to make fields like 'batch_id' available at the top level. ROOT_DATA_CLASSES = { "DataBatch": ds.DataBatch, } # Primitive types that should be treated as leaves in the tree LEAF_TYPES = {int, float, str, bool} class ProfileEditorWindow(tk.Toplevel): """A Toplevel window for managing export profiles.""" def __init__(self, master, controller, profiles: List[ExportProfile]): super().__init__(master) self.master = master self.controller = controller # Work on a deep copy to easily detect unsaved changes self.profiles = copy.deepcopy(profiles) self._original_profiles_dict = {p.name: p.to_dict() for p in self.profiles} self._init_window() self._init_vars() self._create_widgets() self._populate_available_fields_tree() self._load_profiles_to_combobox() self.protocol("WM_DELETE_WINDOW", self._on_close) def _init_window(self): """Initializes window properties.""" self.title("Export Profile Editor") self.geometry("900x600") self.transient(self.master) # Keep this window on top of the main one self.grab_set() # Modal behavior def _init_vars(self): """Initializes Tkinter variables.""" self.selected_profile_name = tk.StringVar() self.selected_fields_var = tk.Variable(value=[]) def _create_widgets(self): """Creates the main layout and widgets for the editor.""" main_pane = ttk.PanedWindow(self, orient=tk.HORIZONTAL) main_pane.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) # --- Left Frame: Profile Management --- profile_mgmt_frame = ttk.LabelFrame(main_pane, text="Profiles") main_pane.add(profile_mgmt_frame, weight=1) profile_mgmt_frame.columnconfigure(0, weight=1) cb_frame = ttk.Frame(profile_mgmt_frame) cb_frame.grid(row=0, column=0, sticky="ew", padx=5, pady=5) cb_frame.columnconfigure(0, weight=1) self.profile_combobox = ttk.Combobox(cb_frame, textvariable=self.selected_profile_name, state="readonly") self.profile_combobox.grid(row=0, column=0, sticky="ew") self.profile_combobox.bind("<>", self._on_profile_selected) btn_frame = ttk.Frame(profile_mgmt_frame) btn_frame.grid(row=1, column=0, sticky="ew", padx=5) btn_frame.columnconfigure((0, 1, 2), weight=1) ttk.Button(btn_frame, text="New", command=self._on_new_profile).grid(row=0, column=0, sticky="ew", padx=2) ttk.Button(btn_frame, text="Delete", command=self._on_delete_profile).grid(row=0, column=1, sticky="ew", padx=2) # --- Middle Frame: Available Fields --- fields_frame = ttk.LabelFrame(main_pane, text="Available Fields") main_pane.add(fields_frame, weight=2) fields_frame.rowconfigure(0, weight=1) fields_frame.columnconfigure(0, weight=1) self.fields_tree = ttk.Treeview(fields_frame, selectmode="browse") self.fields_tree.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) ysb = ttk.Scrollbar(fields_frame, orient='vertical', command=self.fields_tree.yview) self.fields_tree.configure(yscrollcommand=ysb.set) ysb.grid(row=0, column=1, sticky='ns') # --- Right Frame: Selected Fields and Actions --- selected_frame_container = ttk.Frame(main_pane) main_pane.add(selected_frame_container, weight=2) selected_frame_container.rowconfigure(0, weight=1) selected_frame_container.columnconfigure(1, weight=1) action_btn_frame = ttk.Frame(selected_frame_container) action_btn_frame.grid(row=0, column=0, sticky="ns", padx=5, pady=5) ttk.Button(action_btn_frame, text=">>", command=self._add_field).grid(pady=5) ttk.Button(action_btn_frame, text="<<", command=self._remove_field).grid(pady=5) ttk.Button(action_btn_frame, text="Up", command=lambda: self._move_field(-1)).grid(pady=20) ttk.Button(action_btn_frame, text="Down", command=lambda: self._move_field(1)).grid(pady=5) selected_fields_frame = ttk.LabelFrame(selected_frame_container, text="Selected Fields for Profile") selected_fields_frame.grid(row=0, column=1, sticky="nsew") selected_fields_frame.rowconfigure(0, weight=1) selected_fields_frame.columnconfigure(0, weight=1) self.selected_listbox = tk.Listbox(selected_fields_frame, listvariable=self.selected_fields_var, selectmode=tk.SINGLE) self.selected_listbox.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) ysb_list = ttk.Scrollbar(selected_fields_frame, orient='vertical', command=self.selected_listbox.yview) self.selected_listbox.config(yscrollcommand=ysb_list.set) ysb_list.grid(row=0, column=1, sticky='ns') # --- Bottom Frame: Save/Cancel --- bottom_frame = ttk.Frame(self) bottom_frame.pack(fill=tk.X, padx=10, pady=(0, 10)) ttk.Button(bottom_frame, text="Save & Close", command=self._on_save_and_close).pack(side=tk.RIGHT) ttk.Button(bottom_frame, text="Cancel", command=self._on_close).pack(side=tk.RIGHT, padx=5) def _populate_available_fields_tree(self): """Introspects data structures and populates the Treeview.""" for name, class_obj in ROOT_DATA_CLASSES.items(): # Special handling for DataBatch to show its synthetic 'batch_id' if name == "DataBatch": self.fields_tree.insert("", "end", iid="batch_id", text="batch_id", values=("batch_id",)) # Now inspect its real fields, starting with an empty path self._recursive_populate_tree(class_obj, "", "") def _recursive_populate_tree(self, class_obj: Type, parent_id: str, current_path: str): """Recursively explores dataclasses to build the tree.""" if not dataclasses.is_dataclass(class_obj): return for field in dataclasses.fields(class_obj): field_name = field.name field_type = field.type # --- THIS IS THE CORRECTED LINE --- new_path = f"{current_path}.{field_name}" if current_path else field_name node_id = new_path # Check if field type is another dataclass (a branch) if dataclasses.is_dataclass(field_type): child_node = self.fields_tree.insert(parent_id, "end", iid=node_id, text=field_name) self._recursive_populate_tree(field_type, child_node, new_path) # Check if it's a primitive type (a leaf) elif field_type in LEAF_TYPES: self.fields_tree.insert(parent_id, "end", iid=node_id, text=field_name, values=(new_path,)) # Handle List[dataclass] or other complex types if needed in future else: self.fields_tree.insert(parent_id, "end", iid=node_id, text=f"{field_name} [{field_type.__name__}]") def _load_profiles_to_combobox(self): """Updates the profile combobox with current profile names.""" profile_names = [p.name for p in self.profiles] self.profile_combobox['values'] = profile_names if profile_names: self.selected_profile_name.set(profile_names[0]) self._load_profile_into_ui() def _get_current_profile(self) -> ExportProfile | None: """Finds the profile object matching the current combobox selection.""" name = self.selected_profile_name.get() return next((p for p in self.profiles if p.name == name), None) def _on_profile_selected(self, event=None): """Handles changing the profile selection.""" # Here you could add logic to check for unsaved changes before switching self._load_profile_into_ui() def _load_profile_into_ui(self): """Loads the fields of the currently selected profile into the listbox.""" profile = self._get_current_profile() if not profile: self.selected_fields_var.set([]) return display_list = [f.column_name for f in profile.fields] self.selected_fields_var.set(display_list) def _add_field(self): """Adds the selected field from the tree to the current profile.""" selected_item_id = self.fields_tree.focus() if not selected_item_id: return # We only add leaf nodes which have a data path data_path = self.fields_tree.item(selected_item_id, "values") if not data_path: messagebox.showinfo("Cannot Add Field", "Please select a specific data field (a leaf node), not a category.", parent=self) return data_path = data_path[0] column_name = selected_item_id.split('.')[-1] profile = self._get_current_profile() if not profile: return # Avoid duplicates if any(f.data_path == data_path for f in profile.fields): messagebox.showinfo("Duplicate Field", f"The field '{column_name}' is already in the profile.", parent=self) return profile.fields.append(ExportField(column_name=column_name, data_path=data_path)) self._load_profile_into_ui() def _remove_field(self): """Removes the selected field from the current profile.""" selection = self.selected_listbox.curselection() if not selection: return index_to_remove = selection[0] profile = self._get_current_profile() if not profile: return del profile.fields[index_to_remove] self._load_profile_into_ui() def _move_field(self, direction: int): """Moves the selected field up or down in the list.""" selection = self.selected_listbox.curselection() if not selection: return index = selection[0] new_index = index + direction profile = self._get_current_profile() if not profile or not (0 <= new_index < len(profile.fields)): return fields = profile.fields fields.insert(new_index, fields.pop(index)) self._load_profile_into_ui() self.selected_listbox.selection_set(new_index) def _on_new_profile(self): """Creates a new, empty profile.""" name = simpledialog.askstring("New Profile", "Enter a name for the new profile:", parent=self) if not name or not name.strip(): return if any(p.name == name for p in self.profiles): messagebox.showerror("Error", f"A profile with the name '{name}' already exists.", parent=self) return new_profile = ExportProfile(name=name.strip()) self.profiles.append(new_profile) self._load_profiles_to_combobox() self.selected_profile_name.set(name) self._load_profile_into_ui() def _on_delete_profile(self): """Deletes the currently selected profile.""" profile = self._get_current_profile() if not profile: return if messagebox.askyesno("Confirm Delete", f"Are you sure you want to delete the profile '{profile.name}'?", parent=self): self.profiles.remove(profile) self._load_profiles_to_combobox() def _check_unsaved_changes(self) -> bool: """Checks if any profiles have been modified since last save.""" current_profiles_dict = {p.name: p.to_dict() for p in self.profiles} return current_profiles_dict != self._original_profiles_dict def _on_save_and_close(self): """Saves all profiles and closes the window.""" log.info("Saving export profiles and closing editor.") self.controller.save_export_profiles(self.profiles) self.destroy() def _on_close(self): """Handles the window close event.""" if self._check_unsaved_changes(): response = messagebox.askyesnocancel( "Unsaved Changes", "You have unsaved changes. Would you like to save them before closing?", parent=self ) if response is True: # Yes self._on_save_and_close() elif response is False: # No self.destroy() else: # Cancel return else: self.destroy()