feat: Enhance ExportManager with landscape PDF support and improved graph handling

This commit is contained in:
William Valentin
2025-08-06 12:36:56 -07:00
parent af747c4008
commit bb70aff24f
2 changed files with 611 additions and 31 deletions

View File

@@ -18,11 +18,12 @@ 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.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,
@@ -178,13 +179,23 @@ class ExportManager:
edgecolor="none",
)
# Verify the file was actually created
# 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)
@@ -197,10 +208,10 @@ class ExportManager:
try:
df = self.data_manager.load_data()
# Create PDF document
# Create PDF document in landscape format for better table/graph display
doc = SimpleDocTemplate(
export_path,
pagesize=A4,
pagesize=landscape(A4),
rightMargin=72,
leftMargin=72,
topMargin=72,
@@ -252,24 +263,26 @@ class ExportManager:
# 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, 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)
# 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"])
)
@@ -281,11 +294,11 @@ class ExportManager:
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(PageBreak())
story.append(Paragraph("Data Visualization", styles["Heading2"]))
story.append(Spacer(1, 10))
story.append(
@@ -293,20 +306,15 @@ class ExportManager:
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:
# Start table on new page
story.append(PageBreak())
story.append(Paragraph("Data Table", styles["Heading2"]))
story.append(Spacer(1, 10))
story.append(Spacer(1, 20))
# Prepare table data - limit columns for better PDF formatting
# 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)
@@ -320,11 +328,8 @@ class ExportManager:
]
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)
)
# 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
@@ -333,28 +338,57 @@ class ExportManager:
[str(val) if pd.notna(val) else "" for val in row]
)
# Create table with styling
table = Table(table_data, repeatRows=1)
# 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),
("ALIGN", (0, 0), (-1, -1), "CENTER"),
# Left align for better readability
("ALIGN", (0, 0), (-1, -1), "LEFT"),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), 10),
("BOTTOMPADDING", (0, 0), (-1, 0), 12),
# 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"),
("FONTSIZE", (0, 1), (-1, -1), 8),
# 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"])
)
@@ -362,6 +396,21 @@ class ExportManager:
# 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

View File

@@ -0,0 +1,531 @@
"""
Tests for the ExportManager class.
"""
import os
import tempfile
import pytest
from pathlib import Path
from unittest.mock import Mock, patch, MagicMock
import pandas as pd
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
from src.export_manager import ExportManager
class TestExportManager:
"""Test cases for the ExportManager class."""
@pytest.fixture
def mock_data_manager(self):
"""Create a mock data manager with sample data."""
mock_dm = Mock()
sample_data = pd.DataFrame({
'date': ['2025-01-01', '2025-01-02'],
'depression': [5, 6],
'anxiety': [3, 4],
'bupropion': [1, 0],
'bupropion_doses': ['09:00:150mg', ''],
'note': ['feeling better', 'neutral day']
})
mock_dm.load_data.return_value = sample_data
return mock_dm
@pytest.fixture
def mock_graph_manager(self):
"""Create a mock graph manager."""
mock_gm = Mock()
mock_fig = Mock()
mock_gm.fig = mock_fig
mock_gm.update_graph = Mock()
return mock_gm
@pytest.fixture
def mock_medicine_manager(self):
"""Create a mock medicine manager."""
mock_mm = Mock()
mock_mm.get_medicine_keys.return_value = ['bupropion', 'hydroxyzine']
return mock_mm
@pytest.fixture
def mock_pathology_manager(self):
"""Create a mock pathology manager."""
mock_pm = Mock()
mock_pm.get_pathology_keys.return_value = ['depression', 'anxiety']
return mock_pm
@pytest.fixture
def export_manager(self, mock_data_manager, mock_graph_manager,
mock_medicine_manager, mock_pathology_manager, mock_logger):
"""Create an ExportManager instance with mocked dependencies."""
return ExportManager(
mock_data_manager,
mock_graph_manager,
mock_medicine_manager,
mock_pathology_manager,
mock_logger
)
def test_init(self, export_manager, mock_data_manager, mock_graph_manager,
mock_medicine_manager, mock_pathology_manager, mock_logger):
"""Test ExportManager initialization."""
assert export_manager.data_manager == mock_data_manager
assert export_manager.graph_manager == mock_graph_manager
assert export_manager.medicine_manager == mock_medicine_manager
assert export_manager.pathology_manager == mock_pathology_manager
assert export_manager.logger == mock_logger
def test_export_data_to_json_success(self, export_manager):
"""Test successful JSON export."""
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
temp_path = f.name
try:
result = export_manager.export_data_to_json(temp_path)
assert result is True
assert os.path.exists(temp_path)
# Verify file content
import json
with open(temp_path, 'r') as f:
data = json.load(f)
assert 'metadata' in data
assert 'entries' in data
assert data['metadata']['total_entries'] == 2
assert len(data['entries']) == 2
finally:
if os.path.exists(temp_path):
os.unlink(temp_path)
def test_export_data_to_json_empty_data(self, export_manager):
"""Test JSON export with empty data."""
export_manager.data_manager.load_data.return_value = pd.DataFrame()
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
temp_path = f.name
try:
result = export_manager.export_data_to_json(temp_path)
assert result is False
export_manager.logger.warning.assert_called_with("No data to export")
finally:
if os.path.exists(temp_path):
os.unlink(temp_path)
def test_export_data_to_xml_success(self, export_manager):
"""Test successful XML export."""
with tempfile.NamedTemporaryFile(mode='w', suffix='.xml', delete=False) as f:
temp_path = f.name
try:
result = export_manager.export_data_to_xml(temp_path)
assert result is True
assert os.path.exists(temp_path)
# Verify file content
with open(temp_path, 'r') as f:
content = f.read()
assert 'thechart_data' in content
assert 'metadata' in content
assert 'entries' in content
finally:
if os.path.exists(temp_path):
os.unlink(temp_path)
def test_export_data_to_xml_empty_data(self, export_manager):
"""Test XML export with empty data."""
export_manager.data_manager.load_data.return_value = pd.DataFrame()
with tempfile.NamedTemporaryFile(mode='w', suffix='.xml', delete=False) as f:
temp_path = f.name
try:
result = export_manager.export_data_to_xml(temp_path)
assert result is False
export_manager.logger.warning.assert_called_with("No data to export")
finally:
if os.path.exists(temp_path):
os.unlink(temp_path)
@patch('matplotlib.pyplot.draw')
@patch('matplotlib.pyplot.pause')
def test_save_graph_as_image_success(self, mock_pause, mock_draw, export_manager):
"""Test successful graph image saving."""
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
# Mock the savefig method
export_manager.graph_manager.fig.savefig = Mock()
# Create a dummy image file to simulate successful save
image_path = temp_path / "graph.png"
image_path.write_bytes(b"fake image data")
# Mock the savefig to create the file
def mock_savefig(path, **kwargs):
Path(path).write_bytes(b"fake image data")
export_manager.graph_manager.fig.savefig.side_effect = mock_savefig
result = export_manager._save_graph_as_image(temp_path)
assert result is not None
assert str(temp_path / "graph.png") in result
export_manager.graph_manager.update_graph.assert_called_once()
def test_save_graph_as_image_no_graph_manager(self, export_manager):
"""Test graph image saving with no graph manager."""
export_manager.graph_manager = None
with tempfile.TemporaryDirectory() as temp_dir:
result = export_manager._save_graph_as_image(Path(temp_dir))
assert result is None
export_manager.logger.warning.assert_called_with(
"No graph manager available for export"
)
def test_save_graph_as_image_no_figure(self, export_manager):
"""Test graph image saving with no figure."""
export_manager.graph_manager.fig = None
with tempfile.TemporaryDirectory() as temp_dir:
result = export_manager._save_graph_as_image(Path(temp_dir))
assert result is None
export_manager.logger.warning.assert_called_with(
"No graph figure available for export"
)
def test_save_graph_as_image_empty_data(self, export_manager):
"""Test graph image saving with empty data."""
export_manager.data_manager.load_data.return_value = pd.DataFrame()
with tempfile.TemporaryDirectory() as temp_dir:
result = export_manager._save_graph_as_image(Path(temp_dir))
assert result is None
export_manager.logger.warning.assert_called_with(
"No data available to update graph for export"
)
@patch('src.export_manager.ExportManager._save_graph_as_image')
@patch('src.export_manager.SimpleDocTemplate')
def test_export_to_pdf_success(self, mock_doc, mock_save_graph, export_manager):
"""Test successful PDF export."""
# Mock graph image saving
mock_save_graph.return_value = "/tmp/test_graph.png"
# Mock document building
mock_doc_instance = Mock()
mock_doc.return_value = mock_doc_instance
# Mock os.path.exists to return True for the image
with patch('os.path.exists', return_value=True):
with patch('os.remove'): # Mock cleanup
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as f:
temp_path = f.name
try:
result = export_manager.export_to_pdf(temp_path, include_graph=True)
assert result is True
mock_doc_instance.build.assert_called_once()
export_manager.logger.info.assert_called_with(
f"Data exported to PDF: {temp_path}"
)
finally:
if os.path.exists(temp_path):
os.unlink(temp_path)
@patch('src.export_manager.ExportManager._save_graph_as_image')
@patch('src.export_manager.SimpleDocTemplate')
def test_export_to_pdf_no_graph(self, mock_doc, mock_save_graph, export_manager):
"""Test PDF export without graph."""
# Mock document building
mock_doc_instance = Mock()
mock_doc.return_value = mock_doc_instance
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as f:
temp_path = f.name
try:
result = export_manager.export_to_pdf(temp_path, include_graph=False)
assert result is True
mock_doc_instance.build.assert_called_once()
mock_save_graph.assert_not_called()
finally:
if os.path.exists(temp_path):
os.unlink(temp_path)
@patch('src.export_manager.SimpleDocTemplate')
def test_export_to_pdf_empty_data(self, mock_doc, export_manager):
"""Test PDF export with empty data."""
export_manager.data_manager.load_data.return_value = pd.DataFrame()
# Mock document building
mock_doc_instance = Mock()
mock_doc.return_value = mock_doc_instance
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as f:
temp_path = f.name
try:
result = export_manager.export_to_pdf(temp_path, include_graph=False)
assert result is True
mock_doc_instance.build.assert_called_once()
finally:
if os.path.exists(temp_path):
os.unlink(temp_path)
@patch('src.export_manager.SimpleDocTemplate')
def test_export_to_pdf_exception(self, mock_doc, export_manager):
"""Test PDF export with exception."""
# Mock document building to raise exception
mock_doc.side_effect = Exception("PDF generation failed")
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as f:
temp_path = f.name
try:
result = export_manager.export_to_pdf(temp_path)
assert result is False
export_manager.logger.error.assert_called()
finally:
if os.path.exists(temp_path):
os.unlink(temp_path)
def test_get_export_info_with_data(self, export_manager):
"""Test getting export info with data."""
info = export_manager.get_export_info()
assert info['total_entries'] == 2
assert info['has_data'] is True
assert info['date_range']['start'] == '2025-01-01'
assert info['date_range']['end'] == '2025-01-02'
assert info['pathologies'] == ['depression', 'anxiety']
assert info['medicines'] == ['bupropion', 'hydroxyzine']
def test_get_export_info_empty_data(self, export_manager):
"""Test getting export info with empty data."""
export_manager.data_manager.load_data.return_value = pd.DataFrame()
info = export_manager.get_export_info()
assert info['total_entries'] == 0
assert info['has_data'] is False
assert info['date_range']['start'] is None
assert info['date_range']['end'] is None
class TestExportManagerIntegration:
"""Integration tests for ExportManager with real-like scenarios."""
@pytest.fixture
def real_data_manager(self, temp_csv_file, mock_logger):
"""Create a data manager with real test data."""
from src.medicine_manager import MedicineManager
from src.pathology_manager import PathologyManager
from src.data_manager import DataManager
# Create managers with real data
medicine_manager = MedicineManager(logger=mock_logger)
pathology_manager = PathologyManager(logger=mock_logger)
# Initialize data manager
data_manager = DataManager(temp_csv_file, mock_logger, medicine_manager, pathology_manager)
# Add some test data
test_entries = [
['2025-01-01', 5, 3, 6, 7, 1, '09:00:150mg', 0, '', 0, '', 0, '', 0, '', 'feeling better today'],
['2025-01-02', 6, 4, 5, 6, 0, '', 1, '22:00:25mg', 0, '', 0, '', 0, '', 'neutral day'],
['2025-01-03', 4, 2, 7, 8, 1, '09:00:150mg|21:00:150mg', 0, '', 0, '', 0, '', 0, '', 'good sleep, multiple doses'],
]
for entry in test_entries:
data_manager.add_entry(entry)
return data_manager, medicine_manager, pathology_manager
@pytest.fixture
def real_graph_manager(self, mock_logger):
"""Create a real graph manager for testing."""
import tkinter as tk
import tkinter.ttk as ttk
from src.graph_manager import GraphManager
from src.medicine_manager import MedicineManager
from src.pathology_manager import PathologyManager
# Create minimal tkinter setup
root = tk.Tk()
root.withdraw() # Hide window
frame = ttk.Frame(root)
medicine_manager = MedicineManager(logger=mock_logger)
pathology_manager = PathologyManager(logger=mock_logger)
graph_manager = GraphManager(frame, medicine_manager, pathology_manager)
# Store root for cleanup
graph_manager._test_root = root
return graph_manager
def test_full_pdf_export_integration(self, real_data_manager, real_graph_manager, mock_logger):
"""Test complete PDF export with real managers and improved formatting."""
data_manager, medicine_manager, pathology_manager = real_data_manager
# Create export manager
export_manager = ExportManager(
data_manager, real_graph_manager, medicine_manager, pathology_manager, mock_logger
)
# Update graph with data
df = data_manager.load_data()
assert not df.empty, "Test data should be loaded"
real_graph_manager.update_graph(df)
# Test PDF export
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as f:
temp_pdf_path = f.name
try:
success = export_manager.export_to_pdf(temp_pdf_path, include_graph=True)
# Verify export success
assert success is True, "PDF export should succeed"
assert os.path.exists(temp_pdf_path), "PDF file should be created"
assert os.path.getsize(temp_pdf_path) > 1000, "PDF should have reasonable size"
# Check that info log was called for successful export
mock_logger.info.assert_any_call(f"Data exported to PDF: {temp_pdf_path}")
finally:
# Cleanup
if hasattr(real_graph_manager, '_test_root'):
real_graph_manager._test_root.destroy()
if os.path.exists(temp_pdf_path):
os.unlink(temp_pdf_path)
def test_pdf_export_with_landscape_format(self, real_data_manager, real_graph_manager, mock_logger):
"""Test PDF export uses landscape format and proper dimensions."""
data_manager, medicine_manager, pathology_manager = real_data_manager
export_manager = ExportManager(
data_manager, real_graph_manager, medicine_manager, pathology_manager, mock_logger
)
# Update graph with data
df = data_manager.load_data()
real_graph_manager.update_graph(df)
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as f:
temp_pdf_path = f.name
try:
# Mock the SimpleDocTemplate to verify landscape format
with patch('src.export_manager.SimpleDocTemplate') as mock_doc:
mock_doc_instance = Mock()
mock_doc.return_value = mock_doc_instance
success = export_manager.export_to_pdf(temp_pdf_path, include_graph=True)
# Verify SimpleDocTemplate was called with landscape pagesize
mock_doc.assert_called_once()
call_args = mock_doc.call_args
# Check that landscape format is used
from reportlab.lib.pagesizes import landscape, A4
expected_pagesize = landscape(A4)
assert call_args[1]['pagesize'] == expected_pagesize
finally:
if hasattr(real_graph_manager, '_test_root'):
real_graph_manager._test_root.destroy()
if os.path.exists(temp_pdf_path):
os.unlink(temp_pdf_path)
def test_pdf_export_table_formatting(self, real_data_manager, real_graph_manager, mock_logger):
"""Test PDF export uses improved table formatting."""
data_manager, medicine_manager, pathology_manager = real_data_manager
export_manager = ExportManager(
data_manager, real_graph_manager, medicine_manager, pathology_manager, mock_logger
)
df = data_manager.load_data()
real_graph_manager.update_graph(df)
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as f:
temp_pdf_path = f.name
try:
# Mock Table to verify column widths and styling
with patch('src.export_manager.Table') as mock_table:
mock_table_instance = Mock()
mock_table.return_value = mock_table_instance
with patch('src.export_manager.SimpleDocTemplate') as mock_doc:
mock_doc_instance = Mock()
mock_doc.return_value = mock_doc_instance
success = export_manager.export_to_pdf(temp_pdf_path, include_graph=True)
# Verify Table was called with column widths
mock_table.assert_called()
call_args = mock_table.call_args
# Check that colWidths parameter is provided
assert 'colWidths' in call_args[1]
col_widths = call_args[1]['colWidths']
# Verify column widths are reasonable
assert len(col_widths) > 0
from reportlab.lib.units import inch
assert all(width > 0.5 * inch for width in col_widths) # All columns at least 0.5"
finally:
if hasattr(real_graph_manager, '_test_root'):
real_graph_manager._test_root.destroy()
if os.path.exists(temp_pdf_path):
os.unlink(temp_pdf_path)
def test_pdf_export_with_long_notes(self, real_data_manager, real_graph_manager, mock_logger):
"""Test PDF export handles long notes without truncation."""
data_manager, medicine_manager, pathology_manager = real_data_manager
# Add entry with very long note
long_note = "This is a very long note that would have been truncated in the old system but should now be displayed in full with proper word wrapping and formatting in the improved PDF export system."
data_manager.add_entry(['2025-01-04', 3, 2, 5, 6, 0, '', 0, '', 0, '', 0, '', 0, '', long_note])
export_manager = ExportManager(
data_manager, real_graph_manager, medicine_manager, pathology_manager, mock_logger
)
df = data_manager.load_data()
real_graph_manager.update_graph(df)
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as f:
temp_pdf_path = f.name
try:
success = export_manager.export_to_pdf(temp_pdf_path, include_graph=True)
assert success is True
# Verify that the long note was not truncated by checking data processing
df_processed = data_manager.load_data()
note_entry = df_processed[df_processed['date'] == '2025-01-04']['note'].iloc[0]
assert long_note in note_entry # Full note should be preserved
finally:
if hasattr(real_graph_manager, '_test_root'):
real_graph_manager._test_root.destroy()
if os.path.exists(temp_pdf_path):
os.unlink(temp_pdf_path)