435 lines
17 KiB
Python
435 lines
17 KiB
Python
"""
|
|
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, landscape
|
|
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
|
from reportlab.lib.units import inch
|
|
from reportlab.platypus import (
|
|
Image,
|
|
PageBreak,
|
|
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",
|
|
)
|
|
|
|
# Ensure the figure data is properly flushed to disk
|
|
import matplotlib.pyplot as plt
|
|
|
|
plt.draw()
|
|
plt.pause(0.01) # Small pause to ensure file is written
|
|
|
|
# Verify the file was actually created and has content
|
|
if not temp_image_path.exists():
|
|
self.logger.error(
|
|
f"Graph image file was not created: {temp_image_path}"
|
|
)
|
|
return None
|
|
|
|
if temp_image_path.stat().st_size == 0:
|
|
self.logger.error(f"Graph image file is empty: {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 in landscape format for better table/graph display
|
|
doc = SimpleDocTemplate(
|
|
export_path,
|
|
pagesize=landscape(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"
|
|
graph_path = None
|
|
|
|
try:
|
|
graph_path = self._save_graph_as_image(temp_dir)
|
|
if graph_path and os.path.exists(graph_path):
|
|
# Add page break before graph for full page display
|
|
story.append(PageBreak())
|
|
|
|
story.append(
|
|
Paragraph("Data Visualization", styles["Heading2"])
|
|
)
|
|
story.append(Spacer(1, 20))
|
|
|
|
# Full page graph - maintain proportions while maximizing size
|
|
# Let ReportLab scale proportionally to fit landscape page
|
|
img = Image(graph_path, width=9 * inch, height=5.4 * inch)
|
|
story.append(img)
|
|
else:
|
|
# Graph not available, add a note instead
|
|
story.append(PageBreak())
|
|
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"],
|
|
)
|
|
)
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error including graph in PDF: {str(e)}")
|
|
# Add error note instead of failing completely
|
|
story.append(PageBreak())
|
|
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"]
|
|
)
|
|
)
|
|
|
|
# Add data table if we have data
|
|
if not df.empty:
|
|
# Start table on new page
|
|
story.append(PageBreak())
|
|
story.append(Paragraph("Data Table", styles["Heading2"]))
|
|
story.append(Spacer(1, 20))
|
|
|
|
# Prepare table data - include all columns for full display
|
|
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()
|
|
|
|
# Don't truncate notes - landscape format has full width
|
|
# Keep notes as-is for complete data visibility
|
|
|
|
# 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]
|
|
)
|
|
|
|
# Calculate optimal column widths for landscape format
|
|
col_widths = []
|
|
for col in available_columns:
|
|
if col == "date":
|
|
col_widths.append(1.0 * inch) # Fixed width for dates
|
|
elif col == "note":
|
|
col_widths.append(3.5 * inch) # Wider for notes
|
|
elif col in self.pathology_manager.get_pathology_keys():
|
|
col_widths.append(0.8 * inch) # Narrow for pathology scores
|
|
elif col in self.medicine_manager.get_medicine_keys():
|
|
col_widths.append(0.8 * inch) # Narrow for medicine status
|
|
else:
|
|
col_widths.append(1.0 * inch) # Default width
|
|
|
|
# Create table with specified column widths and better styling
|
|
table = Table(table_data, colWidths=col_widths, repeatRows=1)
|
|
table.setStyle(
|
|
TableStyle(
|
|
[
|
|
("BACKGROUND", (0, 0), (-1, 0), colors.grey),
|
|
("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke),
|
|
# Left align for better readability
|
|
("ALIGN", (0, 0), (-1, -1), "LEFT"),
|
|
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
|
("FONTSIZE", (0, 0), (-1, 0), 10),
|
|
# Add more padding for better readability
|
|
("LEFTPADDING", (0, 0), (-1, -1), 8),
|
|
("RIGHTPADDING", (0, 0), (-1, -1), 8),
|
|
("TOPPADDING", (0, 0), (-1, -1), 6),
|
|
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
|
|
("BACKGROUND", (0, 1), (-1, -1), colors.beige),
|
|
("FONTNAME", (0, 1), (-1, -1), "Helvetica"),
|
|
# Slightly larger font for better readability
|
|
("FONTSIZE", (0, 1), (-1, -1), 9),
|
|
("GRID", (0, 0), (-1, -1), 1, colors.black),
|
|
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
|
("WORDWRAP", (0, 0), (-1, -1), True),
|
|
# Alternating row colors for better visual separation
|
|
(
|
|
"ROWBACKGROUNDS",
|
|
(0, 1),
|
|
(-1, -1),
|
|
[colors.beige, colors.lightgrey],
|
|
),
|
|
]
|
|
)
|
|
)
|
|
|
|
story.append(table)
|
|
else:
|
|
story.append(PageBreak())
|
|
story.append(
|
|
Paragraph("No data available to export.", styles["Normal"])
|
|
)
|
|
|
|
# Build PDF
|
|
doc.build(story)
|
|
|
|
# Clean up temporary image file after PDF is built
|
|
if include_graph:
|
|
temp_dir = Path(export_path).parent / "temp_export"
|
|
if graph_path and os.path.exists(graph_path):
|
|
try:
|
|
os.remove(graph_path)
|
|
self.logger.debug(f"Cleaned up temporary image: {graph_path}")
|
|
except OSError as e:
|
|
self.logger.warning(f"Could not remove temp image: {e}")
|
|
|
|
# Clean up temp directory if empty
|
|
if temp_dir.exists():
|
|
with contextlib.suppress(OSError):
|
|
temp_dir.rmdir()
|
|
|
|
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,
|
|
}
|