feat: Enhance export functionality with DataFrame support and UI improvements

This commit is contained in:
William Valentin
2025-08-08 12:26:21 -07:00
parent b039447a1f
commit f5c9b79a33
6 changed files with 253 additions and 17 deletions
+15 -6
View File
@@ -54,10 +54,12 @@ class ExportManager:
self.pathology_manager = pathology_manager
self.logger = logger
def export_data_to_json(self, export_path: str) -> bool:
def export_data_to_json(
self, export_path: str, df: pd.DataFrame | None = None
) -> bool:
"""Export CSV data to JSON format."""
try:
df = self.data_manager.load_data()
df = df if df is not None else self.data_manager.load_data()
if df.empty:
self.logger.warning("No data to export")
return False
@@ -87,10 +89,12 @@ class ExportManager:
self.logger.error(f"Error exporting to JSON: {str(e)}")
return False
def export_data_to_xml(self, export_path: str) -> bool:
def export_data_to_xml(
self, export_path: str, df: pd.DataFrame | None = None
) -> bool:
"""Export CSV data to XML format."""
try:
df = self.data_manager.load_data()
df = df if df is not None else self.data_manager.load_data()
if df.empty:
self.logger.warning("No data to export")
return False
@@ -203,10 +207,15 @@ class ExportManager:
self.logger.error(f"Error saving graph image: {str(e)}")
return None
def export_to_pdf(self, export_path: str, include_graph: bool = True) -> bool:
def export_to_pdf(
self,
export_path: str,
include_graph: bool = True,
df: pd.DataFrame | None = None,
) -> bool:
"""Export data and optionally graph to PDF format."""
try:
df = self.data_manager.load_data()
df = df if df is not None else self.data_manager.load_data()
# Create PDF document in landscape format for better table/graph display
doc = SimpleDocTemplate(
+36 -4
View File
@@ -5,6 +5,7 @@ Provides a GUI interface for exporting data and graphs to various formats.
"""
import tkinter as tk
from collections.abc import Callable
from pathlib import Path
from tkinter import filedialog, messagebox, ttk
@@ -14,9 +15,15 @@ from export_manager import ExportManager
class ExportWindow:
"""Export window for data and graph export functionality."""
def __init__(self, parent: tk.Tk, export_manager: ExportManager) -> None:
def __init__(
self,
parent: tk.Tk,
export_manager: ExportManager,
get_current_filtered_df: Callable[[], object] | None = None,
) -> None:
self.parent = parent
self.export_manager = export_manager
self._get_current_filtered_df = get_current_filtered_df
# Create the export window
self.window = tk.Toplevel(parent)
@@ -113,6 +120,21 @@ Medicines: {", ".join(export_info["medicines"])}"""
)
graph_check.pack(anchor=tk.W, pady=(0, 10))
# Export scope option
self.scope_var = tk.StringVar(value="all")
scope_frame = ttk.Frame(options_frame)
scope_frame.pack(fill=tk.X, pady=(0, 10))
ttk.Label(scope_frame, text="Scope:").pack(side=tk.LEFT)
ttk.Radiobutton(
scope_frame, text="All data", variable=self.scope_var, value="all"
).pack(side=tk.LEFT, padx=10)
ttk.Radiobutton(
scope_frame,
text="Current (filtered) view",
variable=self.scope_var,
value="filtered",
).pack(side=tk.LEFT)
# Format selection
format_label = ttk.Label(options_frame, text="Export Format:")
format_label.pack(anchor=tk.W)
@@ -182,17 +204,27 @@ Medicines: {", ".join(export_info["medicines"])}"""
if not filename:
return
# Determine scope DataFrame (if requested and available)
scoped_df = None
if self.scope_var.get() == "filtered" and self._get_current_filtered_df:
try:
scoped_df = self._get_current_filtered_df()
except Exception:
scoped_df = None
# Perform export based on selected format
success = False
try:
if selected_format == "JSON":
success = self.export_manager.export_data_to_json(filename)
success = self.export_manager.export_data_to_json(
filename, df=scoped_df
)
elif selected_format == "XML":
success = self.export_manager.export_data_to_xml(filename)
success = self.export_manager.export_data_to_xml(filename, df=scoped_df)
elif selected_format == "PDF":
include_graph = self.include_graph_var.get()
success = self.export_manager.export_to_pdf(
filename, include_graph=include_graph
filename, include_graph=include_graph, df=scoped_df
)
if success:
+14 -1
View File
@@ -681,7 +681,20 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
def _open_export_window(self) -> None:
"""Open the export window."""
self.ui_manager.update_status("Opening export window", "info")
ExportWindow(self.root, self.export_manager)
def _get_current_filtered_df():
try:
if self.current_filtered_data is not None:
return self.current_filtered_data
# If no live filtered DF, but filters are active, compute one-off
if self.data_filter.get_filter_summary().get("has_filters"):
df = self.data_manager.load_data()
return self.data_filter.apply_filters(df)
except Exception:
return None
return None
ExportWindow(self.root, self.export_manager, _get_current_filtered_df)
if hasattr(self.ui_manager, "show_toast"):
self.ui_manager.show_toast("Export window opened", 1200)
+6
View File
@@ -19,6 +19,12 @@ _DEFAULTS: dict[str, Any] = {
"last_window_geometry": "",
# Keep window always on top
"always_on_top": False,
# Search/filter UI state
"search_panel_visible": False,
"last_filter_state": None,
# Table column UX
"column_widths": {},
"last_sort": {"column": None, "ascending": True},
}
_PREFERENCES: dict[str, Any] = dict(_DEFAULTS)
+120 -6
View File
@@ -2,9 +2,10 @@
import tkinter as tk
from collections.abc import Callable
from tkinter import ttk
from tkinter import messagebox, simpledialog, ttk
from init import logger
from preferences import get_pref, save_preferences, set_pref
from search_filter import DataFilter, QuickFilters, SearchHistory
@@ -41,10 +42,10 @@ class SearchFilterWidget:
# Visibility and UI init state
self.is_visible = False
self._ui_initialized = False
self.frame: ttk.LabelFrame | None = None
self.frame = None
# Debouncing mechanism to reduce filter update frequency
self._update_timer: str | None = None
self._update_timer = None
self._debounce_delay = 450 # milliseconds
# History and UI state variables
@@ -53,10 +54,13 @@ class SearchFilterWidget:
self.start_date_var = tk.StringVar()
self.end_date_var = tk.StringVar()
# Presets state
self.preset_var = tk.StringVar()
# Medicine and pathology filter variables
self.medicine_vars: dict[str, tk.StringVar] = {}
self.pathology_min_vars: dict[str, tk.StringVar] = {}
self.pathology_max_vars: dict[str, tk.StringVar] = {}
self.medicine_vars = {}
self.pathology_min_vars = {}
self.pathology_max_vars = {}
def _setup_ui(self) -> None:
"""Set up the search and filter UI."""
@@ -68,9 +72,29 @@ class SearchFilterWidget:
content_frame.pack(fill="both", expand=True)
# Top row: Search and Quick filters
# Top row: Presets, Search and Quick filters
top_row = ttk.Frame(content_frame)
top_row.pack(fill="x", pady=(0, 5))
# Presets section (leftmost)
presets_frame = ttk.Frame(top_row)
presets_frame.pack(side="left", padx=(0, 10))
ttk.Label(presets_frame, text="Preset:").pack(side="left")
self.preset_combo = ttk.Combobox(
presets_frame, textvariable=self.preset_var, state="readonly", width=18
)
self._refresh_presets_combo()
self.preset_combo.pack(side="left", padx=(5, 5))
ttk.Button(presets_frame, text="Load", command=self._load_preset).pack(
side="left", padx=(0, 2)
)
ttk.Button(presets_frame, text="Save", command=self._save_preset).pack(
side="left", padx=(0, 2)
)
ttk.Button(presets_frame, text="Delete", command=self._delete_preset).pack(
side="left"
)
# Search section (left side of top row)
search_frame = ttk.Frame(top_row)
search_frame.pack(side="left", fill="x", expand=True, padx=(0, 10))
@@ -438,6 +462,96 @@ class SearchFilterWidget:
self.status_label.config(text=status_text)
# ---------------------
# Presets management
# ---------------------
def _refresh_presets_combo(self) -> None:
presets = get_pref("filter_presets", {}) or {}
names = sorted(presets.keys())
if hasattr(self, "preset_combo") and self.preset_combo:
self.preset_combo["values"] = names
if names and not self.preset_var.get():
self.preset_var.set(names[0])
def _apply_filter_summary(self, summary: dict) -> None:
"""Apply a saved summary dict into the DataFilter and UI, then update."""
import contextlib
if not isinstance(summary, dict):
return
# Clear then set pieces
self.data_filter.clear_all_filters()
self.data_filter.set_search_term(summary.get("search_term", ""))
filt = summary.get("filters", {}) or {}
# Date
date_rng = filt.get("date_range") or {}
self.data_filter.set_date_range_filter(
date_rng.get("start") or None, date_rng.get("end") or None
)
# Medicines
meds = filt.get("medicines") or {}
for key in meds.get("taken", []) or []:
self.data_filter.set_medicine_filter(key, True)
for key in meds.get("not_taken", []) or []:
self.data_filter.set_medicine_filter(key, False)
# Pathologies
paths = filt.get("pathologies") or {}
for key, range_text in paths.items():
with contextlib.suppress(Exception):
s = str(range_text)
parts = s.split("-")
mn = parts[0].strip() if parts else ""
mx = parts[1].strip() if len(parts) > 1 else ""
mn_i = int(mn) if mn and mn.lower() != "any" else None
mx_i = int(mx) if mx and mx.lower() != "any" else None
self.data_filter.set_pathology_range_filter(key, mn_i, mx_i)
# Sync UI and notify
self.sync_ui_from_filter()
self.update_callback()
def _load_preset(self) -> None:
name = self.preset_var.get().strip()
if not name:
return
presets = get_pref("filter_presets", {}) or {}
summary = presets.get(name)
if not summary:
messagebox.showwarning("Preset", f"Preset '{name}' not found.")
return
self._apply_filter_summary(summary)
def _save_preset(self) -> None:
# Ask for a name
name = simpledialog.askstring("Save Preset", "Preset name:", parent=self.parent)
if not name:
return
name = name.strip()
if not name:
return
presets = get_pref("filter_presets", {}) or {}
presets[name] = self.data_filter.get_filter_summary()
set_pref("filter_presets", presets)
save_preferences()
self._refresh_presets_combo()
self.preset_var.set(name)
self._update_status()
def _delete_preset(self) -> None:
name = self.preset_var.get().strip()
if not name:
return
if not messagebox.askyesno(
"Delete Preset", f"Delete preset '{name}'?", parent=self.parent
):
return
presets = get_pref("filter_presets", {}) or {}
if name in presets:
del presets[name]
set_pref("filter_presets", presets)
save_preferences()
self.preset_var.set("")
self._refresh_presets_combo()
def get_widget(self) -> ttk.LabelFrame | None:
"""Get the main widget for embedding in UI (may be None until shown)."""
return self.frame
+62
View File
@@ -12,6 +12,7 @@ from PIL import Image, ImageTk
from medicine_manager import MedicineManager
from pathology_manager import PathologyManager
from preferences import get_pref, save_preferences, set_pref
from tooltip_system import TooltipManager
@@ -410,6 +411,28 @@ class UIManager:
for col, width, anchor in col_settings:
tree.column(col, width=width, anchor=anchor)
# Apply saved column widths if available
try:
saved_widths = get_pref("column_widths", {}) or {}
if isinstance(saved_widths, dict):
for col in tree["columns"]:
w = saved_widths.get(col)
if isinstance(w, int) and w > 0:
tree.column(col, width=w)
except Exception:
pass
# Initialize last sort from preferences
try:
last_sort = get_pref("last_sort", {}) or {}
col = last_sort.get("column")
asc = last_sort.get("ascending", True)
if col in tree["columns"]:
self._last_sorted_column = col
self._last_sorted_ascending = bool(asc)
except Exception:
pass
tree.pack(side="left", fill="both", expand=True)
# Add scrollbars with optimized scroll handling
@@ -422,6 +445,9 @@ class UIManager:
# Optimize tree scrolling performance
self._optimize_tree_scrolling(tree)
# Install debounced save of column widths
self._install_column_width_persistence(tree)
return {"frame": table_frame, "tree": tree}
# ------------------------------------------------------------------
@@ -462,6 +488,13 @@ class UIManager:
# Re-apply alternating row tags after sort
self.normalize_tree_stripes(tree)
# Persist last sort
try:
set_pref("last_sort", {"column": column, "ascending": ascending})
save_preferences()
except Exception:
pass
def _sort_tree_column_direction(
self, tree: ttk.Treeview, column: str, ascending: bool
) -> None:
@@ -572,6 +605,35 @@ class UIManager:
# Ensure alternating stripes are normalized after updates
self.normalize_tree_stripes(tree)
# --- Column width persistence helpers ---
def _install_column_width_persistence(self, tree: ttk.Treeview) -> None:
import contextlib
self._col_width_save_after_id = None
def _debounced_save(*_args):
if getattr(self, "_col_width_save_after_id", None):
with contextlib.suppress(Exception):
self.root.after_cancel(self._col_width_save_after_id)
self._col_width_save_after_id = self.root.after(600, _save_now)
def _save_now():
widths = {}
for col in tree["columns"]:
try:
widths[col] = int(tree.column(col, option="width"))
except Exception:
continue
try:
set_pref("column_widths", widths)
save_preferences()
except Exception:
pass
self._col_width_save_after_id = None
tree.bind("<ButtonRelease-1>", _debounced_save, add="+")
tree.bind("<Configure>", _debounced_save, add="+")
def normalize_tree_stripes(self, tree: ttk.Treeview) -> None:
"""Normalize alternating row tags based on current visual order.