290 lines
13 KiB
Python
290 lines
13 KiB
Python
"""
|
|
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("<<ComboboxSelected>>", 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() |