""" 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, }