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.pathology_manager = pathology_manager
self.logger = logger 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.""" """Export CSV data to JSON format."""
try: try:
df = self.data_manager.load_data() df = df if df is not None else self.data_manager.load_data()
if df.empty: if df.empty:
self.logger.warning("No data to export") self.logger.warning("No data to export")
return False return False
@@ -87,10 +89,12 @@ class ExportManager:
self.logger.error(f"Error exporting to JSON: {str(e)}") self.logger.error(f"Error exporting to JSON: {str(e)}")
return False 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.""" """Export CSV data to XML format."""
try: try:
df = self.data_manager.load_data() df = df if df is not None else self.data_manager.load_data()
if df.empty: if df.empty:
self.logger.warning("No data to export") self.logger.warning("No data to export")
return False return False
@@ -203,10 +207,15 @@ class ExportManager:
self.logger.error(f"Error saving graph image: {str(e)}") self.logger.error(f"Error saving graph image: {str(e)}")
return None 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.""" """Export data and optionally graph to PDF format."""
try: 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 # Create PDF document in landscape format for better table/graph display
doc = SimpleDocTemplate( 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 import tkinter as tk
from collections.abc import Callable
from pathlib import Path from pathlib import Path
from tkinter import filedialog, messagebox, ttk from tkinter import filedialog, messagebox, ttk
@@ -14,9 +15,15 @@ from export_manager import ExportManager
class ExportWindow: class ExportWindow:
"""Export window for data and graph export functionality.""" """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.parent = parent
self.export_manager = export_manager self.export_manager = export_manager
self._get_current_filtered_df = get_current_filtered_df
# Create the export window # Create the export window
self.window = tk.Toplevel(parent) self.window = tk.Toplevel(parent)
@@ -113,6 +120,21 @@ Medicines: {", ".join(export_info["medicines"])}"""
) )
graph_check.pack(anchor=tk.W, pady=(0, 10)) 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 selection
format_label = ttk.Label(options_frame, text="Export Format:") format_label = ttk.Label(options_frame, text="Export Format:")
format_label.pack(anchor=tk.W) format_label.pack(anchor=tk.W)
@@ -182,17 +204,27 @@ Medicines: {", ".join(export_info["medicines"])}"""
if not filename: if not filename:
return 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 # Perform export based on selected format
success = False success = False
try: try:
if selected_format == "JSON": 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": 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": elif selected_format == "PDF":
include_graph = self.include_graph_var.get() include_graph = self.include_graph_var.get()
success = self.export_manager.export_to_pdf( success = self.export_manager.export_to_pdf(
filename, include_graph=include_graph filename, include_graph=include_graph, df=scoped_df
) )
if success: 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: def _open_export_window(self) -> None:
"""Open the export window.""" """Open the export window."""
self.ui_manager.update_status("Opening export window", "info") 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"): if hasattr(self.ui_manager, "show_toast"):
self.ui_manager.show_toast("Export window opened", 1200) self.ui_manager.show_toast("Export window opened", 1200)
+6
View File
@@ -19,6 +19,12 @@ _DEFAULTS: dict[str, Any] = {
"last_window_geometry": "", "last_window_geometry": "",
# Keep window always on top # Keep window always on top
"always_on_top": False, "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) _PREFERENCES: dict[str, Any] = dict(_DEFAULTS)
+120 -6
View File
@@ -2,9 +2,10 @@
import tkinter as tk import tkinter as tk
from collections.abc import Callable from collections.abc import Callable
from tkinter import ttk from tkinter import messagebox, simpledialog, ttk
from init import logger from init import logger
from preferences import get_pref, save_preferences, set_pref
from search_filter import DataFilter, QuickFilters, SearchHistory from search_filter import DataFilter, QuickFilters, SearchHistory
@@ -41,10 +42,10 @@ class SearchFilterWidget:
# Visibility and UI init state # Visibility and UI init state
self.is_visible = False self.is_visible = False
self._ui_initialized = False self._ui_initialized = False
self.frame: ttk.LabelFrame | None = None self.frame = None
# Debouncing mechanism to reduce filter update frequency # Debouncing mechanism to reduce filter update frequency
self._update_timer: str | None = None self._update_timer = None
self._debounce_delay = 450 # milliseconds self._debounce_delay = 450 # milliseconds
# History and UI state variables # History and UI state variables
@@ -53,10 +54,13 @@ class SearchFilterWidget:
self.start_date_var = tk.StringVar() self.start_date_var = tk.StringVar()
self.end_date_var = tk.StringVar() self.end_date_var = tk.StringVar()
# Presets state
self.preset_var = tk.StringVar()
# Medicine and pathology filter variables # Medicine and pathology filter variables
self.medicine_vars: dict[str, tk.StringVar] = {} self.medicine_vars = {}
self.pathology_min_vars: dict[str, tk.StringVar] = {} self.pathology_min_vars = {}
self.pathology_max_vars: dict[str, tk.StringVar] = {} self.pathology_max_vars = {}
def _setup_ui(self) -> None: def _setup_ui(self) -> None:
"""Set up the search and filter UI.""" """Set up the search and filter UI."""
@@ -68,9 +72,29 @@ class SearchFilterWidget:
content_frame.pack(fill="both", expand=True) content_frame.pack(fill="both", expand=True)
# Top row: Search and Quick filters # Top row: Search and Quick filters
# Top row: Presets, Search and Quick filters
top_row = ttk.Frame(content_frame) top_row = ttk.Frame(content_frame)
top_row.pack(fill="x", pady=(0, 5)) 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 section (left side of top row)
search_frame = ttk.Frame(top_row) search_frame = ttk.Frame(top_row)
search_frame.pack(side="left", fill="x", expand=True, padx=(0, 10)) 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) 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: def get_widget(self) -> ttk.LabelFrame | None:
"""Get the main widget for embedding in UI (may be None until shown).""" """Get the main widget for embedding in UI (may be None until shown)."""
return self.frame return self.frame
+62
View File
@@ -12,6 +12,7 @@ from PIL import Image, ImageTk
from medicine_manager import MedicineManager from medicine_manager import MedicineManager
from pathology_manager import PathologyManager from pathology_manager import PathologyManager
from preferences import get_pref, save_preferences, set_pref
from tooltip_system import TooltipManager from tooltip_system import TooltipManager
@@ -410,6 +411,28 @@ class UIManager:
for col, width, anchor in col_settings: for col, width, anchor in col_settings:
tree.column(col, width=width, anchor=anchor) 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) tree.pack(side="left", fill="both", expand=True)
# Add scrollbars with optimized scroll handling # Add scrollbars with optimized scroll handling
@@ -422,6 +445,9 @@ class UIManager:
# Optimize tree scrolling performance # Optimize tree scrolling performance
self._optimize_tree_scrolling(tree) self._optimize_tree_scrolling(tree)
# Install debounced save of column widths
self._install_column_width_persistence(tree)
return {"frame": table_frame, "tree": tree} return {"frame": table_frame, "tree": tree}
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -462,6 +488,13 @@ class UIManager:
# Re-apply alternating row tags after sort # Re-apply alternating row tags after sort
self.normalize_tree_stripes(tree) 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( def _sort_tree_column_direction(
self, tree: ttk.Treeview, column: str, ascending: bool self, tree: ttk.Treeview, column: str, ascending: bool
) -> None: ) -> None:
@@ -572,6 +605,35 @@ class UIManager:
# Ensure alternating stripes are normalized after updates # Ensure alternating stripes are normalized after updates
self.normalize_tree_stripes(tree) 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: def normalize_tree_stripes(self, tree: ttk.Treeview) -> None:
"""Normalize alternating row tags based on current visual order. """Normalize alternating row tags based on current visual order.