Feat: add export functionality with GUI for data and graphs
Build and Push Docker Image / build-and-push (push) Has been cancelled

- Implemented ExportWindow class for exporting data and graphs in various formats (JSON, XML, PDF).
- Integrated ExportManager to handle export logic.
- Added export option in the main application menu.
- Enhanced user interface with data summary and export options.
- Included error handling and success messages for export operations.
- Updated dependencies in the lock file to include reportlab and lxml for PDF generation.
This commit is contained in:
William Valentin
2025-08-02 10:00:24 -07:00
parent 156dcd1651
commit b7a22524d7
11 changed files with 1135 additions and 3 deletions
+385
View File
@@ -0,0 +1,385 @@
"""
Export Manager for TheChart Application
Handles exporting data and graphs to various formats:
- CSV data to JSON, XML
- Graphs to PDF (with data tables)
"""
import contextlib
import json
import logging
import os
from datetime import datetime
from pathlib import Path
from typing import Any
from xml.dom import minidom
from xml.etree.ElementTree import Element, SubElement, tostring
import pandas as pd
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import inch
from reportlab.platypus import (
Image,
Paragraph,
SimpleDocTemplate,
Spacer,
Table,
TableStyle,
)
from data_manager import DataManager
from graph_manager import GraphManager
from medicine_manager import MedicineManager
from pathology_manager import PathologyManager
class ExportManager:
"""Handle data and graph export operations."""
def __init__(
self,
data_manager: DataManager,
graph_manager: GraphManager,
medicine_manager: MedicineManager,
pathology_manager: PathologyManager,
logger: logging.Logger,
) -> None:
self.data_manager = data_manager
self.graph_manager = graph_manager
self.medicine_manager = medicine_manager
self.pathology_manager = pathology_manager
self.logger = logger
def export_data_to_json(self, export_path: str) -> bool:
"""Export CSV data to JSON format."""
try:
df = self.data_manager.load_data()
if df.empty:
self.logger.warning("No data to export")
return False
# Convert DataFrame to dictionary with better structure
export_data = {
"metadata": {
"export_date": datetime.now().isoformat(),
"total_entries": len(df),
"date_range": {
"start": df["date"].min() if not df.empty else None,
"end": df["date"].max() if not df.empty else None,
},
"pathologies": list(self.pathology_manager.get_pathology_keys()),
"medicines": list(self.medicine_manager.get_medicine_keys()),
},
"entries": df.to_dict(orient="records"),
}
with open(export_path, "w", encoding="utf-8") as f:
json.dump(export_data, f, indent=2, ensure_ascii=False)
self.logger.info(f"Data exported to JSON: {export_path}")
return True
except Exception as e:
self.logger.error(f"Error exporting to JSON: {str(e)}")
return False
def export_data_to_xml(self, export_path: str) -> bool:
"""Export CSV data to XML format."""
try:
df = self.data_manager.load_data()
if df.empty:
self.logger.warning("No data to export")
return False
# Create root element
root = Element("thechart_data")
# Add metadata
metadata = SubElement(root, "metadata")
SubElement(metadata, "export_date").text = datetime.now().isoformat()
SubElement(metadata, "total_entries").text = str(len(df))
# Date range
date_range = SubElement(metadata, "date_range")
SubElement(date_range, "start").text = (
df["date"].min() if not df.empty else ""
)
SubElement(date_range, "end").text = (
df["date"].max() if not df.empty else ""
)
# Pathologies
pathologies = SubElement(metadata, "pathologies")
for pathology in self.pathology_manager.get_pathology_keys():
SubElement(pathologies, "pathology").text = pathology
# Medicines
medicines = SubElement(metadata, "medicines")
for medicine in self.medicine_manager.get_medicine_keys():
SubElement(medicines, "medicine").text = medicine
# Add entries
entries = SubElement(root, "entries")
for _, row in df.iterrows():
entry = SubElement(entries, "entry")
for column, value in row.items():
elem = SubElement(entry, column.replace(" ", "_"))
elem.text = str(value) if pd.notna(value) else ""
# Pretty print XML
rough_string = tostring(root, "utf-8")
reparsed = minidom.parseString(rough_string)
pretty_xml = reparsed.toprettyxml(indent=" ")
with open(export_path, "w", encoding="utf-8") as f:
f.write(pretty_xml)
self.logger.info(f"Data exported to XML: {export_path}")
return True
except Exception as e:
self.logger.error(f"Error exporting to XML: {str(e)}")
return False
def _save_graph_as_image(self, temp_dir: Path) -> str | None:
"""Save current graph as temporary image for PDF inclusion."""
try:
# Check if graph manager exists
if self.graph_manager is None:
self.logger.warning("No graph manager available for export")
return None
# Check if graph manager and figure exist
if not hasattr(self.graph_manager, "fig") or self.graph_manager.fig is None:
self.logger.warning("No graph figure available for export")
return None
# Ensure graph is up to date with current data
df = self.data_manager.load_data()
if not df.empty:
self.graph_manager.update_graph(df)
else:
self.logger.warning("No data available to update graph for export")
return None
# Ensure temp directory exists
temp_dir.mkdir(parents=True, exist_ok=True)
temp_image_path = temp_dir / "graph.png"
# Save the current figure
self.graph_manager.fig.savefig(
str(temp_image_path),
dpi=150,
bbox_inches="tight",
facecolor="white",
edgecolor="none",
)
# Verify the file was actually created
if not temp_image_path.exists():
self.logger.error(
f"Graph image file was not created: {temp_image_path}"
)
return None
self.logger.info(f"Graph image saved successfully: {temp_image_path}")
return str(temp_image_path)
except Exception as e:
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:
"""Export data and optionally graph to PDF format."""
try:
df = self.data_manager.load_data()
# Create PDF document
doc = SimpleDocTemplate(
export_path,
pagesize=A4,
rightMargin=72,
leftMargin=72,
topMargin=72,
bottomMargin=18,
)
# Get styles
styles = getSampleStyleSheet()
title_style = ParagraphStyle(
"CustomTitle",
parent=styles["Heading1"],
fontSize=18,
spaceAfter=30,
textColor=colors.darkblue,
)
story = []
# Title
story.append(Paragraph("TheChart - Medication Tracker Export", title_style))
story.append(Spacer(1, 20))
# Export metadata
export_info = [
f"Export Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
f"Total Entries: {len(df) if not df.empty else 0}",
]
if not df.empty:
export_info.extend(
[
f"Date Range: {df['date'].min()} to {df['date'].max()}",
(
"Pathologies: "
+ ", ".join(self.pathology_manager.get_pathology_keys())
),
(
"Medicines: "
+ ", ".join(self.medicine_manager.get_medicine_keys())
),
]
)
for info in export_info:
story.append(Paragraph(info, styles["Normal"]))
story.append(Spacer(1, 20))
# Include graph if requested and available
if include_graph:
temp_dir = Path(export_path).parent / "temp_export"
try:
graph_path = self._save_graph_as_image(temp_dir)
if graph_path and os.path.exists(graph_path):
story.append(
Paragraph("Data Visualization", styles["Heading2"])
)
story.append(Spacer(1, 10))
# Add graph image
img = Image(graph_path, width=6 * inch, height=3.6 * inch)
story.append(img)
story.append(Spacer(1, 20))
# Clean up temp image
os.remove(graph_path)
else:
# Graph not available, add a note instead
story.append(
Paragraph("Data Visualization", styles["Heading2"])
)
story.append(Spacer(1, 10))
story.append(
Paragraph(
"Graph not available - no data to visualize or graph "
"not generated yet.",
styles["Normal"],
)
)
story.append(Spacer(1, 20))
except Exception as e:
self.logger.error(f"Error including graph in PDF: {str(e)}")
# Add error note instead of failing completely
story.append(Paragraph("Data Visualization", styles["Heading2"]))
story.append(Spacer(1, 10))
story.append(
Paragraph(
f"Graph could not be included: {str(e)}", styles["Normal"]
)
)
story.append(Spacer(1, 20))
finally:
# Clean up temp directory
if temp_dir.exists():
with contextlib.suppress(OSError):
temp_dir.rmdir()
# Add data table if we have data
if not df.empty:
story.append(Paragraph("Data Table", styles["Heading2"]))
story.append(Spacer(1, 10))
# Prepare table data - limit columns for better PDF formatting
display_columns = ["date"]
for pathology_key in self.pathology_manager.get_pathology_keys():
display_columns.append(pathology_key)
for medicine_key in self.medicine_manager.get_medicine_keys():
display_columns.append(medicine_key)
display_columns.append("note")
# Filter dataframe to display columns that exist
available_columns = [
col for col in display_columns if col in df.columns
]
display_df = df[available_columns].copy()
# Truncate long notes for better table formatting
if "note" in display_df.columns:
display_df["note"] = display_df["note"].apply(
lambda x: (str(x)[:50] + "...") if len(str(x)) > 50 else str(x)
)
# Convert to table data
table_data = [available_columns] # Headers
for _, row in display_df.iterrows():
table_data.append(
[str(val) if pd.notna(val) else "" for val in row]
)
# Create table with styling
table = Table(table_data, repeatRows=1)
table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.grey),
("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke),
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), 10),
("BOTTOMPADDING", (0, 0), (-1, 0), 12),
("BACKGROUND", (0, 1), (-1, -1), colors.beige),
("FONTNAME", (0, 1), (-1, -1), "Helvetica"),
("FONTSIZE", (0, 1), (-1, -1), 8),
("GRID", (0, 0), (-1, -1), 1, colors.black),
("VALIGN", (0, 0), (-1, -1), "TOP"),
]
)
)
story.append(table)
else:
story.append(
Paragraph("No data available to export.", styles["Normal"])
)
# Build PDF
doc.build(story)
self.logger.info(f"Data exported to PDF: {export_path}")
return True
except Exception as e:
self.logger.error(f"Error exporting to PDF: {str(e)}")
return False
def get_export_info(self) -> dict[str, Any]:
"""Get information about available data for export."""
df = self.data_manager.load_data()
return {
"total_entries": len(df) if not df.empty else 0,
"date_range": {
"start": df["date"].min() if not df.empty else None,
"end": df["date"].max() if not df.empty else None,
},
"pathologies": list(self.pathology_manager.get_pathology_keys()),
"medicines": list(self.medicine_manager.get_medicine_keys()),
"has_data": not df.empty,
}
+247
View File
@@ -0,0 +1,247 @@
"""
Export Window for TheChart Application
Provides a GUI interface for exporting data and graphs to various formats.
"""
import tkinter as tk
from pathlib import Path
from tkinter import filedialog, messagebox, ttk
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:
self.parent = parent
self.export_manager = export_manager
# Create the export window
self.window = tk.Toplevel(parent)
self.window.title("Export Data")
self.window.geometry("500x450") # Made taller to ensure buttons are visible
self.window.resizable(False, False)
# Center the window
self._center_window()
# Make window modal
self.window.transient(parent)
self.window.grab_set()
# Setup the UI
self._setup_ui()
def _center_window(self) -> None:
"""Center the export window on the parent window."""
self.window.update_idletasks()
# Get window dimensions
width = self.window.winfo_width()
height = self.window.winfo_height()
# Get parent window position and size
parent_x = self.parent.winfo_rootx()
parent_y = self.parent.winfo_rooty()
parent_width = self.parent.winfo_width()
parent_height = self.parent.winfo_height()
# Calculate position to center on parent
x = parent_x + (parent_width // 2) - (width // 2)
y = parent_y + (parent_height // 2) - (height // 2)
self.window.geometry(f"{width}x{height}+{x}+{y}")
def _setup_ui(self) -> None:
"""Setup the export window UI."""
# Main frame
main_frame = ttk.Frame(self.window, padding="15")
main_frame.pack(fill=tk.BOTH, expand=True)
# Title
title_label = ttk.Label(
main_frame, text="Export Data & Graphs", font=("Arial", 14, "bold")
)
title_label.pack(pady=(0, 15))
# Create scrollable content area for the main content
content_frame = ttk.Frame(main_frame)
content_frame.pack(fill=tk.BOTH, expand=True)
# Export info section
self._create_info_section(content_frame)
# Export options section
self._create_options_section(content_frame)
# Buttons section - always at the bottom
self._create_buttons_section(main_frame)
def _create_info_section(self, parent: ttk.Frame) -> None:
"""Create the data information section."""
info_frame = ttk.LabelFrame(parent, text="Data Summary", padding="10")
info_frame.pack(fill=tk.X, pady=(0, 20))
# Get export info
export_info = self.export_manager.get_export_info()
# Display information
if export_info["has_data"]:
info_text = f"""Total Entries: {export_info["total_entries"]}
Date Range: {export_info["date_range"]["start"]} to {export_info["date_range"]["end"]}
Pathologies: {", ".join(export_info["pathologies"])}
Medicines: {", ".join(export_info["medicines"])}"""
else:
info_text = "No data available for export."
info_label = ttk.Label(info_frame, text=info_text, justify=tk.LEFT)
info_label.pack(anchor=tk.W)
def _create_options_section(self, parent: ttk.Frame) -> None:
"""Create the export options section."""
options_frame = ttk.LabelFrame(parent, text="Export Options", padding="10")
options_frame.pack(fill=tk.X, pady=(0, 20))
# Include graph option (for PDF export)
self.include_graph_var = tk.BooleanVar(value=True)
graph_check = ttk.Checkbutton(
options_frame,
text="Include graph in PDF export",
variable=self.include_graph_var,
)
graph_check.pack(anchor=tk.W, pady=(0, 10))
# Format selection
format_label = ttk.Label(options_frame, text="Export Format:")
format_label.pack(anchor=tk.W)
self.format_var = tk.StringVar(value="JSON")
formats = ["JSON", "XML", "PDF"]
for fmt in formats:
radio = ttk.Radiobutton(
options_frame, text=fmt, variable=self.format_var, value=fmt
)
radio.pack(anchor=tk.W, padx=(20, 0))
def _create_buttons_section(self, parent: ttk.Frame) -> None:
"""Create the buttons section."""
# Add a separator for visual clarity
separator = ttk.Separator(parent, orient="horizontal")
separator.pack(fill=tk.X, pady=(10, 10))
button_frame = ttk.Frame(parent)
button_frame.pack(fill=tk.X, pady=(0, 10))
# Export button with more prominent styling
export_btn = ttk.Button(
button_frame, text="Export...", command=self._handle_export
)
export_btn.pack(side=tk.LEFT, padx=(10, 10), pady=5)
# Cancel button
cancel_btn = ttk.Button(
button_frame, text="Cancel", command=self.window.destroy
)
cancel_btn.pack(side=tk.RIGHT, padx=(10, 10), pady=5)
def _handle_export(self) -> None:
"""Handle the export button click."""
# Check if we have data to export
export_info = self.export_manager.get_export_info()
if not export_info["has_data"]:
messagebox.showwarning(
"No Data", "There is no data available to export.", parent=self.window
)
return
# Get selected format
selected_format = self.format_var.get()
# Define file types for dialog
file_types = {
"JSON": [("JSON files", "*.json"), ("All files", "*.*")],
"XML": [("XML files", "*.xml"), ("All files", "*.*")],
"PDF": [("PDF files", "*.pdf"), ("All files", "*.*")],
}
# Default filename
default_name = f"thechart_export.{selected_format.lower()}"
# Show save dialog
filename = filedialog.asksaveasfilename(
parent=self.window,
title=f"Export as {selected_format}",
defaultextension=f".{selected_format.lower()}",
filetypes=file_types[selected_format],
initialfile=default_name,
)
if not filename:
return
# Perform export based on selected format
success = False
try:
if selected_format == "JSON":
success = self.export_manager.export_data_to_json(filename)
elif selected_format == "XML":
success = self.export_manager.export_data_to_xml(filename)
elif selected_format == "PDF":
include_graph = self.include_graph_var.get()
success = self.export_manager.export_to_pdf(
filename, include_graph=include_graph
)
if success:
messagebox.showinfo(
"Export Successful",
f"Data exported successfully to:\n{filename}",
parent=self.window,
)
# Ask if user wants to open the file location
if messagebox.askyesno(
"Open Location",
"Would you like to open the file location?",
parent=self.window,
):
self._open_file_location(filename)
self.window.destroy()
else:
messagebox.showerror(
"Export Failed",
f"Failed to export data as {selected_format}. "
"Please check the logs for more details.",
parent=self.window,
)
except Exception as e:
messagebox.showerror(
"Export Error",
f"An error occurred during export:\n{str(e)}",
parent=self.window,
)
def _open_file_location(self, filepath: str) -> None:
"""Open the file location in the system file manager."""
try:
file_path = Path(filepath)
directory = file_path.parent
# Use system-specific command to open file manager
import subprocess
import sys
if sys.platform == "win32":
subprocess.run(["explorer", str(directory)], check=False)
elif sys.platform == "darwin":
subprocess.run(["open", str(directory)], check=False)
else: # Linux and other Unix-like systems
subprocess.run(["xdg-open", str(directory)], check=False)
except Exception:
# If opening file location fails, just ignore silently
pass
+22
View File
@@ -9,6 +9,8 @@ import pandas as pd
from constants import LOG_CLEAR, LOG_LEVEL, LOG_PATH
from data_manager import DataManager
from export_manager import ExportManager
from export_window import ExportWindow
from graph_manager import GraphManager
from init import logger
from medicine_management_window import MedicineManagementWindow
@@ -117,6 +119,15 @@ class MedTrackerApp:
graph_frame, self.medicine_manager, self.pathology_manager
)
# Initialize export manager
self.export_manager: ExportManager = ExportManager(
self.data_manager,
self.graph_manager,
self.medicine_manager,
self.pathology_manager,
logger,
)
# --- Create Input Frame ---
input_ui: dict[str, Any] = self.ui_manager.create_input_frame(main_frame)
self.input_frame: ttk.Frame = input_ui["frame"]
@@ -152,6 +163,13 @@ class MedTrackerApp:
menubar = tk.Menu(self.root)
self.root.config(menu=menubar)
# File menu
file_menu = tk.Menu(menubar, tearoff=0)
menubar.add_cascade(label="File", menu=file_menu)
file_menu.add_command(label="Export Data...", command=self._open_export_window)
file_menu.add_separator()
file_menu.add_command(label="Exit", command=self.handle_window_closing)
# Tools menu
tools_menu = tk.Menu(menubar, tearoff=0)
menubar.add_cascade(label="Tools", menu=tools_menu)
@@ -162,6 +180,10 @@ class MedTrackerApp:
label="Manage Medicines...", command=self._open_medicine_manager
)
def _open_export_window(self) -> None:
"""Open the export window."""
ExportWindow(self.root, self.export_manager)
def _open_pathology_manager(self) -> None:
"""Open the pathology management window."""
PathologyManagementWindow(