Soumik Bose commited on
Commit
1539a3f
Β·
1 Parent(s): e903858
pdf_report_generation_helper.py CHANGED
@@ -1,13 +1,85 @@
 
 
 
 
1
  import os
2
  import uuid
3
  import logging
4
- import asyncio
5
  from typing import List, Dict, Any
 
 
 
6
  from pdf_report_generation_service import Q4ReportGenerator
7
- from supabase_service import upload_file_to_supabase # Assuming this exists from your previous context
8
 
9
  logger = logging.getLogger(__name__)
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  async def generate_and_upload_report(
12
  config_content: List[Dict[str, Any]],
13
  file_name: str,
@@ -17,100 +89,106 @@ async def generate_and_upload_report(
17
  author: str = "AI Assistant",
18
  department: str = "Data Analytics",
19
  output_dir: str = "temp_reports",
20
- confidential: bool = False
21
  ) -> str:
22
  """
23
  Generates a PDF report from content configuration, uploads it to Supabase,
24
  and returns the public URL. Cleans up temporary files afterwards.
25
- """
26
-
27
- # 1. Setup paths
28
- os.makedirs(output_dir, exist_ok=True)
29
 
30
- # Ensure filename ends with .pdf
31
- if not file_name.endswith('.pdf'):
32
- file_name += '.pdf'
 
 
 
 
 
 
33
 
34
- # Create a unique filename to prevent collisions locally
35
- unique_id = str(uuid.uuid4())[:8]
36
- local_filename = f"{os.path.splitext(file_name)[0]}_{unique_id}.pdf"
37
-
38
- # 2. Initialize Generator
39
- generator = Q4ReportGenerator(
40
- title=title,
41
- subtitle=subtitle,
42
- author=author,
43
- department=department,
44
- confidential=confidential
45
- )
46
-
47
- # 3. Generate PDF (Blocking I/O, so we run it in a way that doesn't block main loop if called properly)
48
- # In FastAPI, we'll wrap this whole function or the generation part in run_in_threadpool
49
- success = generator.generate(
50
- sections=config_content,
51
- filename=local_filename,
52
- output_dir=output_dir,
53
- cover_page=True
54
- )
55
-
56
- if not success:
57
- raise Exception("Failed to generate PDF report locally.")
58
 
59
- local_file_path = os.path.join(output_dir, local_filename)
 
 
 
 
60
 
61
  try:
62
- # 4. Upload to Supabase
63
- # We use the original desired file_name for the upload (or unique if you prefer)
64
- # Using unique name in cloud storage is usually safer
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  cloud_filename = f"{os.path.splitext(file_name)[0]}_{unique_id}.pdf"
66
 
67
- logger.info(f"Uploading {local_file_path} to Supabase as {cloud_filename}")
68
 
69
- public_url = await upload_file_to_supabase(
 
70
  file_path=local_file_path,
71
  file_name=cloud_filename,
72
  chat_id=chat_id
73
  )
74
 
75
- logger.info(f"Upload successful. URL: {public_url}")
 
 
 
76
 
77
  return public_url
78
 
79
  except Exception as e:
80
- logger.error(f"Failed to upload report: {e}")
81
- raise e
82
 
83
  finally:
84
- # 5. Cleanup - Delete local PDF
85
- if os.path.exists(local_file_path):
86
- try:
87
- os.remove(local_file_path)
88
- logger.info(f"Deleted local file: {local_file_path}")
89
- except Exception as e:
90
- logger.warning(f"Failed to delete local file {local_file_path}: {e}")
91
-
92
- # 6. Cleanup - Delete downloaded images
93
- # The generator class tracks images it downloaded
94
- if hasattr(generator, 'downloaded_images'):
95
- for img_uri in generator.downloaded_images:
96
- # Convert URI back to path if needed (file://...)
97
- if img_uri.startswith('file://'):
98
- img_path = img_uri[7:] # Simple strip, robust implementation depends on OS
99
- # On windows file:///C:/... might need handling
100
- if os.name == 'nt' and img_uri.startswith('file:///'):
101
- img_path = img_uri[8:]
102
-
103
- if os.path.exists(img_path):
104
- try:
105
- os.remove(img_path)
106
- logger.info(f"Deleted temp image: {img_path}")
107
- except Exception as e:
108
- logger.warning(f"Failed to delete image {img_path}: {e}")
109
 
110
- # Try to remove the images directory if empty
111
- images_dir = os.path.join(output_dir, 'images')
112
- if os.path.exists(images_dir):
113
- try:
114
- os.rmdir(images_dir)
115
- except:
116
- pass
 
 
 
1
+ """
2
+ PDF Report Generation Helper - Production Ready
3
+ Handles PDF generation, upload to Supabase, and cleanup
4
+ """
5
  import os
6
  import uuid
7
  import logging
 
8
  from typing import List, Dict, Any
9
+ from pathlib import Path
10
+ from starlette.concurrency import run_in_threadpool
11
+
12
  from pdf_report_generation_service import Q4ReportGenerator
 
13
 
14
  logger = logging.getLogger(__name__)
15
 
16
+ def _upload_to_supabase_sync(file_path: str, file_name: str, chat_id: str) -> str:
17
+ """
18
+ Synchronous wrapper for Supabase upload.
19
+ Import your actual upload function here.
20
+ """
21
+ from supabase_service import upload_file_to_supabase
22
+ return upload_file_to_supabase(
23
+ file_path=file_path,
24
+ file_name=file_name,
25
+ chat_id=chat_id
26
+ )
27
+
28
+ def _generate_pdf_sync(
29
+ generator: Q4ReportGenerator,
30
+ config_content: List[Dict[str, Any]],
31
+ local_file_path: str,
32
+ output_dir: str
33
+ ) -> bool:
34
+ """Synchronous PDF generation wrapper"""
35
+ return generator.generate(
36
+ sections=config_content,
37
+ filename=os.path.basename(local_file_path),
38
+ output_dir=output_dir,
39
+ cover_page=True
40
+ )
41
+
42
+ def _cleanup_files(file_paths: List[str]):
43
+ """Clean up temporary files"""
44
+ for file_path in file_paths:
45
+ if file_path and os.path.exists(file_path):
46
+ try:
47
+ os.remove(file_path)
48
+ logger.info(f"Cleaned up: {file_path}")
49
+ except Exception as e:
50
+ logger.warning(f"Failed to delete {file_path}: {e}")
51
+
52
+ def _cleanup_downloaded_images(generator: Q4ReportGenerator, output_dir: str):
53
+ """Clean up images downloaded during PDF generation"""
54
+ if not hasattr(generator, 'downloaded_images'):
55
+ return
56
+
57
+ for img_uri in generator.downloaded_images:
58
+ try:
59
+ # Convert file:// URI to path
60
+ if img_uri.startswith('file://'):
61
+ # Handle both Unix and Windows paths
62
+ if os.name == 'nt' and img_uri.startswith('file:///'):
63
+ img_path = img_uri[8:] # Windows: file:///C:/...
64
+ else:
65
+ img_path = img_uri[7:] # Unix: file:///path/...
66
+
67
+ if os.path.exists(img_path):
68
+ os.remove(img_path)
69
+ logger.info(f"Deleted temp image: {img_path}")
70
+ except Exception as e:
71
+ logger.warning(f"Failed to delete image {img_uri}: {e}")
72
+
73
+ # Try to remove the images directory if empty
74
+ images_dir = os.path.join(output_dir, 'images')
75
+ if os.path.exists(images_dir):
76
+ try:
77
+ if not os.listdir(images_dir): # Check if empty
78
+ os.rmdir(images_dir)
79
+ logger.info(f"Removed empty images directory: {images_dir}")
80
+ except Exception as e:
81
+ logger.debug(f"Could not remove images directory: {e}")
82
+
83
  async def generate_and_upload_report(
84
  config_content: List[Dict[str, Any]],
85
  file_name: str,
 
89
  author: str = "AI Assistant",
90
  department: str = "Data Analytics",
91
  output_dir: str = "temp_reports",
92
+ confidential: bool = True
93
  ) -> str:
94
  """
95
  Generates a PDF report from content configuration, uploads it to Supabase,
96
  and returns the public URL. Cleans up temporary files afterwards.
 
 
 
 
97
 
98
+ Args:
99
+ config_content: List of section dictionaries for the report
100
+ file_name: Desired filename (without or with .pdf extension)
101
+ chat_id: Chat ID for Supabase storage organization
102
+ title: Report title
103
+ subtitle: Report subtitle
104
+ author: Report author
105
+ department: Department name
106
+ output_dir: Directory for temporary file storage
107
 
108
+ Returns:
109
+ str: Public URL of the uploaded PDF
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
 
111
+ Raises:
112
+ Exception: If PDF generation or upload fails
113
+ """
114
+
115
+ local_file_path = None
116
 
117
  try:
118
+ # 1. Setup paths
119
+ os.makedirs(output_dir, exist_ok=True)
120
+
121
+ # Ensure filename ends with .pdf
122
+ if not file_name.endswith('.pdf'):
123
+ file_name += '.pdf'
124
+
125
+ # Create unique filename to prevent collisions
126
+ unique_id = str(uuid.uuid4())[:8]
127
+ local_filename = f"{os.path.splitext(file_name)[0]}_{unique_id}.pdf"
128
+ local_file_path = os.path.join(output_dir, local_filename)
129
+
130
+ logger.info(f"Starting PDF generation: {local_filename}")
131
+
132
+ # 2. Initialize Generator
133
+ generator = Q4ReportGenerator(
134
+ title=title,
135
+ subtitle=subtitle,
136
+ author=author,
137
+ department=department,
138
+ confidential=confidential
139
+ )
140
+
141
+ # 3. Generate PDF (Run in thread pool to avoid blocking)
142
+ success = await run_in_threadpool(
143
+ _generate_pdf_sync,
144
+ generator=generator,
145
+ config_content=config_content,
146
+ local_file_path=local_file_path,
147
+ output_dir=output_dir
148
+ )
149
+
150
+ if not success:
151
+ raise Exception("PDF generation failed - generator returned False")
152
+
153
+ if not os.path.exists(local_file_path):
154
+ raise Exception(f"PDF file not found after generation: {local_file_path}")
155
+
156
+ # 4. Upload to Supabase (Run in thread pool)
157
  cloud_filename = f"{os.path.splitext(file_name)[0]}_{unique_id}.pdf"
158
 
159
+ logger.info(f"Uploading {local_filename} to Supabase as {cloud_filename}")
160
 
161
+ public_url = await run_in_threadpool(
162
+ _upload_to_supabase_sync,
163
  file_path=local_file_path,
164
  file_name=cloud_filename,
165
  chat_id=chat_id
166
  )
167
 
168
+ if not public_url:
169
+ raise Exception("Upload failed - no URL returned")
170
+
171
+ logger.info(f"Upload successful: {public_url}")
172
 
173
  return public_url
174
 
175
  except Exception as e:
176
+ logger.error(f"Report generation/upload failed: {e}", exc_info=True)
177
+ raise Exception(f"Failed to generate and upload report: {str(e)}")
178
 
179
  finally:
180
+ # 5. Cleanup - Always execute, even if there's an error
181
+ try:
182
+ # Delete local PDF
183
+ if local_file_path:
184
+ _cleanup_files([local_file_path])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
 
186
+ # Delete downloaded images
187
+ if 'generator' in locals():
188
+ await run_in_threadpool(
189
+ _cleanup_downloaded_images,
190
+ generator=generator,
191
+ output_dir=output_dir
192
+ )
193
+ except Exception as cleanup_error:
194
+ logger.warning(f"Cleanup error (non-fatal): {cleanup_error}")
pdf_report_generation_service.py CHANGED
@@ -1,197 +1,475 @@
1
  """
2
- PDF Report Generation Helper - Production Ready
3
- Handles PDF generation, upload to Supabase, and cleanup
4
  """
 
 
 
5
  import os
6
- import uuid
 
7
  import logging
8
- from typing import List, Dict, Any
9
  from pathlib import Path
10
- from starlette.concurrency import run_in_threadpool
11
-
12
- from pdf_report_generation_service import Q4ReportGenerator
13
 
 
 
 
 
 
 
 
 
14
  logger = logging.getLogger(__name__)
15
 
16
- def _upload_to_supabase_sync(file_path: str, file_name: str, chat_id: str) -> str:
17
- """
18
- Synchronous wrapper for Supabase upload.
19
- Import your actual upload function here.
20
- """
21
- from supabase_service import upload_file_to_supabase
22
- return upload_file_to_supabase(
23
- file_path=file_path,
24
- file_name=file_name,
25
- chat_id=chat_id
26
- )
27
-
28
- def _generate_pdf_sync(
29
- generator: Q4ReportGenerator,
30
- config_content: List[Dict[str, Any]],
31
- local_file_path: str,
32
- output_dir: str
33
- ) -> bool:
34
- """Synchronous PDF generation wrapper"""
35
- return generator.generate(
36
- sections=config_content,
37
- filename=os.path.basename(local_file_path),
38
- output_dir=output_dir,
39
- cover_page=True
40
- )
41
-
42
- def _cleanup_files(file_paths: List[str]):
43
- """Clean up temporary files"""
44
- for file_path in file_paths:
45
- if file_path and os.path.exists(file_path):
46
- try:
47
- os.remove(file_path)
48
- logger.info(f"Cleaned up: {file_path}")
49
- except Exception as e:
50
- logger.warning(f"Failed to delete {file_path}: {e}")
51
-
52
- def _cleanup_downloaded_images(generator: Q4ReportGenerator, output_dir: str):
53
- """Clean up images downloaded during PDF generation"""
54
- if not hasattr(generator, 'downloaded_images'):
55
- return
56
 
57
- for img_uri in generator.downloaded_images:
58
- try:
59
- # Convert file:// URI to path
60
- if img_uri.startswith('file://'):
61
- # Handle both Unix and Windows paths
62
- if os.name == 'nt' and img_uri.startswith('file:///'):
63
- img_path = img_uri[8:] # Windows: file:///C:/...
64
- else:
65
- img_path = img_uri[7:] # Unix: file:///path/...
66
-
67
- if os.path.exists(img_path):
68
- os.remove(img_path)
69
- logger.info(f"Deleted temp image: {img_path}")
70
- except Exception as e:
71
- logger.warning(f"Failed to delete image {img_uri}: {e}")
72
 
73
- # Try to remove the images directory if empty
74
- images_dir = os.path.join(output_dir, 'images')
75
- if os.path.exists(images_dir):
76
- try:
77
- if not os.listdir(images_dir): # Check if empty
78
- os.rmdir(images_dir)
79
- logger.info(f"Removed empty images directory: {images_dir}")
80
- except Exception as e:
81
- logger.debug(f"Could not remove images directory: {e}")
82
-
83
- async def generate_and_upload_report(
84
- config_content: List[Dict[str, Any]],
85
- file_name: str,
86
- chat_id: str,
87
- title: str = "Analytics Report",
88
- subtitle: str = "Generated Report",
89
- author: str = "AI Assistant",
90
- department: str = "Data Analytics",
91
- output_dir: str = "temp_reports",
92
- confidential: bool = True
93
- ) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  """
95
- Generates a PDF report from content configuration, uploads it to Supabase,
96
- and returns the public URL. Cleans up temporary files afterwards.
97
 
98
  Args:
99
- config_content: List of section dictionaries for the report
100
- file_name: Desired filename (without or with .pdf extension)
101
- chat_id: Chat ID for Supabase storage organization
102
- title: Report title
103
- subtitle: Report subtitle
104
- author: Report author
105
- department: Department name
106
- output_dir: Directory for temporary file storage
107
 
108
  Returns:
109
- str: Public URL of the uploaded PDF
110
-
111
- Raises:
112
- Exception: If PDF generation or upload fails
113
  """
114
-
115
- local_file_path = None
 
 
 
116
 
117
  try:
118
- # 1. Setup paths
119
- os.makedirs(output_dir, exist_ok=True)
120
-
121
- # Ensure filename ends with .pdf
122
- if not file_name.endswith('.pdf'):
123
- file_name += '.pdf'
124
-
125
- # Create unique filename to prevent collisions
126
- unique_id = str(uuid.uuid4())[:8]
127
- local_filename = f"{os.path.splitext(file_name)[0]}_{unique_id}.pdf"
128
- local_file_path = os.path.join(output_dir, local_filename)
129
 
130
- logger.info(f"Starting PDF generation: {local_filename}")
 
 
 
 
131
 
132
- # 2. Initialize Generator
133
- generator = Q4ReportGenerator(
134
- title=title,
135
- subtitle=subtitle,
136
- author=author,
137
- department=department,
138
- confidential=confidential
139
- )
 
 
 
140
 
141
- # 3. Generate PDF (Run in thread pool to avoid blocking)
142
- success = await run_in_threadpool(
143
- _generate_pdf_sync,
144
- generator=generator,
145
- config_content=config_content,
146
- local_file_path=local_file_path,
147
- output_dir=output_dir
148
- )
149
 
150
- if not success:
151
- raise Exception("PDF generation failed - generator returned False")
152
 
153
- if not os.path.exists(local_file_path):
154
- raise Exception(f"PDF file not found after generation: {local_file_path}")
155
 
156
- # 4. Upload to Supabase (Run in thread pool)
157
- cloud_filename = f"{os.path.splitext(file_name)[0]}_{unique_id}.pdf"
158
 
159
- logger.info(f"Uploading {local_filename} to Supabase as {cloud_filename}")
160
-
161
- public_url = await run_in_threadpool(
162
- _upload_to_supabase_sync,
163
- file_path=local_file_path,
164
- file_name=cloud_filename,
165
- chat_id=chat_id
166
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
 
168
- if not public_url:
169
- raise Exception("Upload failed - no URL returned")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
 
171
- logger.info(f"Upload successful: {public_url}")
 
 
 
 
 
 
172
 
173
- return public_url
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
 
175
- except Exception as e:
176
- logger.error(f"Report generation/upload failed: {e}", exc_info=True)
177
- raise Exception(f"Failed to generate and upload report: {str(e)}")
178
 
179
- finally:
180
- # 5. Cleanup - Always execute, even if there's an error
 
 
 
 
 
181
  try:
182
- # Delete local PDF
183
- if local_file_path:
184
- _cleanup_files([local_file_path])
185
 
186
- # Delete downloaded images
187
- if 'generator' in locals():
188
- await run_in_threadpool(
189
- _cleanup_downloaded_images,
190
- generator=generator,
191
- output_dir=output_dir
192
- )
193
- except Exception as cleanup_error:
194
- logger.warning(f"Cleanup error (non-fatal): {cleanup_error}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
 
196
  # ---------------------------------------------------------
197
  # COMPLETE TEST CASE WITH FULL Q4 REPORT
 
1
  """
2
+ Professional Q4 Report Generator - Complete Working Version
3
+ Generates executive-quality PDF reports with proper image rendering
4
  """
5
+ import markdown
6
+ from weasyprint import HTML, CSS
7
+ import requests
8
  import os
9
+ from datetime import datetime
10
+ from typing import List, Dict, Optional
11
  import logging
 
12
  from pathlib import Path
 
 
 
13
 
14
+ # ---------------------------------------------------------
15
+ # LOGGING CONFIGURATION
16
+ # ---------------------------------------------------------
17
+ logging.basicConfig(
18
+ level=logging.INFO,
19
+ format='%(asctime)s - %(levelname)s - %(message)s',
20
+ datefmt='%Y-%m-%d %H:%M:%S'
21
+ )
22
  logger = logging.getLogger(__name__)
23
 
24
+ # ---------------------------------------------------------
25
+ # EXECUTIVE Q4 REPORT THEME
26
+ # ---------------------------------------------------------
27
+ CSS_STYLE = """
28
+ @page {
29
+ size: letter;
30
+ margin: 0.75in 0.75in 1in 0.75in;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
+ @top-center {
33
+ content: element(header);
34
+ }
 
 
 
 
 
 
 
 
 
 
 
 
35
 
36
+ @bottom-center {
37
+ content: element(footer);
38
+ }
39
+ }
40
+
41
+ body {
42
+ font-family: 'Helvetica', Arial, sans-serif;
43
+ color: #1a1a1a;
44
+ line-height: 1.65;
45
+ font-size: 11pt;
46
+ }
47
+
48
+ .header {
49
+ position: running(header);
50
+ font-size: 9pt;
51
+ color: #666;
52
+ border-bottom: 1px solid #ddd;
53
+ padding-bottom: 6px;
54
+ margin-bottom: 20px;
55
+ }
56
+
57
+ .footer {
58
+ position: running(footer);
59
+ font-size: 9pt;
60
+ color: #666;
61
+ border-top: 1px solid #ddd;
62
+ padding-top: 6px;
63
+ text-align: center;
64
+ }
65
+
66
+ .footer::after {
67
+ content: "Page " counter(page) " of " counter(pages);
68
+ }
69
+
70
+ .confidential-mark {
71
+ color: #d32f2f;
72
+ font-weight: bold;
73
+ float: left;
74
+ }
75
+
76
+ .cover-page {
77
+ text-align: center;
78
+ padding-top: 3in;
79
+ page-break-after: always;
80
+ }
81
+
82
+ .cover-title {
83
+ font-size: 36pt;
84
+ font-weight: bold;
85
+ color: #003366;
86
+ margin-bottom: 0.5in;
87
+ letter-spacing: 1px;
88
+ line-height: 1.2;
89
+ }
90
+
91
+ .cover-subtitle {
92
+ font-size: 20pt;
93
+ color: #0055a5;
94
+ margin-bottom: 1.2in;
95
+ font-weight: 500;
96
+ }
97
+
98
+ .cover-meta {
99
+ font-size: 15pt;
100
+ color: #555;
101
+ margin-bottom: 0.25in;
102
+ line-height: 1.5;
103
+ }
104
+
105
+ .cover-date {
106
+ font-size: 12pt;
107
+ color: #888;
108
+ margin-top: 0.5in;
109
+ }
110
+
111
+ .cover-divider {
112
+ width: 3in;
113
+ height: 3px;
114
+ background: linear-gradient(to right, #003366, #0055a5);
115
+ margin: 0.5in auto;
116
+ }
117
+
118
+ h1 {
119
+ color: #003366;
120
+ font-size: 20pt;
121
+ border-bottom: 3px solid #0055a5;
122
+ padding-bottom: 10px;
123
+ margin-top: 28px;
124
+ margin-bottom: 18px;
125
+ page-break-after: avoid;
126
+ font-weight: bold;
127
+ }
128
+
129
+ h2 {
130
+ color: #0055a5;
131
+ font-size: 15pt;
132
+ margin-top: 22px;
133
+ margin-bottom: 14px;
134
+ page-break-after: avoid;
135
+ font-weight: 600;
136
+ }
137
+
138
+ h3 {
139
+ color: #333;
140
+ font-size: 13pt;
141
+ margin-top: 18px;
142
+ margin-bottom: 12px;
143
+ page-break-after: avoid;
144
+ font-weight: 600;
145
+ }
146
+
147
+ table {
148
+ width: 100%;
149
+ border-collapse: collapse;
150
+ margin: 18px 0;
151
+ font-size: 10pt;
152
+ page-break-inside: avoid;
153
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
154
+ }
155
+
156
+ thead {
157
+ background: linear-gradient(to bottom, #003366, #00264d);
158
+ }
159
+
160
+ th {
161
+ background-color: #003366;
162
+ color: white;
163
+ padding: 12px;
164
+ text-align: left;
165
+ font-weight: bold;
166
+ border: 1px solid #002244;
167
+ font-size: 10.5pt;
168
+ }
169
+
170
+ td {
171
+ padding: 10px 12px;
172
+ border: 1px solid #ddd;
173
+ line-height: 1.5;
174
+ }
175
+
176
+ tr:nth-child(even) {
177
+ background-color: #f8f9fa;
178
+ }
179
+
180
+ tr:nth-child(odd) {
181
+ background-color: #ffffff;
182
+ }
183
+
184
+ .highlight-box {
185
+ background-color: #e8f4f8;
186
+ border-left: 5px solid #0055a5;
187
+ padding: 16px 20px;
188
+ margin: 20px 0;
189
+ page-break-inside: avoid;
190
+ box-shadow: 0 2px 4px rgba(0,0,0,0.05);
191
+ }
192
+
193
+ .key-metric-box {
194
+ background: linear-gradient(135deg, #003366 0%, #0055a5 100%);
195
+ color: white;
196
+ border-radius: 8px;
197
+ padding: 18px 22px;
198
+ margin: 20px 0;
199
+ page-break-inside: avoid;
200
+ box-shadow: 0 3px 6px rgba(0,0,0,0.15);
201
+ }
202
+
203
+ .key-metric-box h3 {
204
+ color: white;
205
+ margin-top: 0;
206
+ }
207
+
208
+ blockquote {
209
+ border-left: 5px solid #003366;
210
+ margin-left: 0;
211
+ padding-left: 20px;
212
+ color: #333;
213
+ font-style: italic;
214
+ background-color: #f8f9fa;
215
+ padding: 14px 14px 14px 20px;
216
+ margin: 18px 0;
217
+ box-shadow: 0 1px 2px rgba(0,0,0,0.05);
218
+ }
219
+
220
+ .figure-container {
221
+ text-align: center;
222
+ margin: 24px 0;
223
+ page-break-inside: avoid;
224
+ }
225
+
226
+ .figure-container img {
227
+ max-width: 100%;
228
+ height: auto;
229
+ border: 1px solid #ddd;
230
+ padding: 8px;
231
+ background: white;
232
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
233
+ }
234
+
235
+ .figure-caption {
236
+ font-size: 9.5pt;
237
+ color: #666;
238
+ margin-top: 10px;
239
+ font-style: italic;
240
+ font-weight: 500;
241
+ }
242
+
243
+ .executive-summary {
244
+ background-color: #f0f7ff;
245
+ border: 2px solid #0055a5;
246
+ padding: 20px;
247
+ margin: 24px 0;
248
+ page-break-inside: avoid;
249
+ border-radius: 5px;
250
+ }
251
+
252
+ .page-break {
253
+ page-break-after: always;
254
+ }
255
+
256
+ ul, ol {
257
+ margin: 14px 0;
258
+ padding-left: 28px;
259
+ }
260
+
261
+ li {
262
+ margin: 8px 0;
263
+ line-height: 1.6;
264
+ }
265
+
266
+ strong {
267
+ font-weight: 600;
268
+ color: #000;
269
+ }
270
+ """
271
+
272
+ # ---------------------------------------------------------
273
+ # IMAGE HANDLING
274
+ # ---------------------------------------------------------
275
+ def fetch_image(url: str, output_dir: str) -> Optional[str]:
276
  """
277
+ Download image from URL to persistent file for PDF rendering.
 
278
 
279
  Args:
280
+ url: The URL of the image
281
+ output_dir: Directory to save downloaded images
 
 
 
 
 
 
282
 
283
  Returns:
284
+ File URI string or None if failed
 
 
 
285
  """
286
+ if not url.startswith(('http://', 'https://')):
287
+ if os.path.exists(url):
288
+ abs_path = os.path.abspath(url)
289
+ return Path(abs_path).as_uri()
290
+ return None
291
 
292
  try:
293
+ img_dir = os.path.join(output_dir, 'images')
294
+ os.makedirs(img_dir, exist_ok=True)
 
 
 
 
 
 
 
 
 
295
 
296
+ headers = {
297
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
298
+ }
299
+ response = requests.get(url, headers=headers, timeout=15)
300
+ response.raise_for_status()
301
 
302
+ # Determine file extension
303
+ content_type = response.headers.get('content-type', '')
304
+ ext = '.jpg'
305
+ if 'png' in content_type:
306
+ ext = '.png'
307
+ elif 'gif' in content_type:
308
+ ext = '.gif'
309
+ elif 'svg' in content_type:
310
+ ext = '.svg'
311
+ elif 'webp' in content_type:
312
+ ext = '.webp'
313
 
314
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S_%f')
315
+ filename = f'img_{timestamp}{ext}'
316
+ filepath = os.path.join(img_dir, filename)
 
 
 
 
 
317
 
318
+ with open(filepath, 'wb') as f:
319
+ f.write(response.content)
320
 
321
+ abs_path = os.path.abspath(filepath)
322
+ file_uri = Path(abs_path).as_uri()
323
 
324
+ logger.info(f"Downloaded image: {url[:50]}... -> {filename}")
325
+ return file_uri
326
 
327
+ except Exception as e:
328
+ logger.warning(f"Failed to download image: {url[:50]}... - {e}")
329
+ return None
330
+
331
+ # ---------------------------------------------------------
332
+ # Q4 REPORT GENERATOR CLASS
333
+ # ---------------------------------------------------------
334
+ class Q4ReportGenerator:
335
+ """Generates executive-quality Q4 reports using WeasyPrint."""
336
+
337
+ def __init__(self,
338
+ title: str = "Q4 Performance Report",
339
+ subtitle: str = None,
340
+ author: str = None,
341
+ department: str = None,
342
+ date: str = None,
343
+ header_text: str = None,
344
+ confidential: bool = True):
345
+ self.title = title
346
+ self.subtitle = subtitle or "Fourth Quarter Analysis"
347
+ self.author = author or "Data Analytics Team"
348
+ self.department = department or "Analytics Department"
349
+ self.date = date or datetime.now().strftime("%B %d, %Y")
350
+ self.confidential = confidential
351
+ self.downloaded_images = []
352
 
353
+ if header_text:
354
+ self.header_text = header_text
355
+ else:
356
+ parts = [self.department, self.subtitle]
357
+ self.header_text = " | ".join(parts)
358
+
359
+ def _build_header(self) -> str:
360
+ return f'<div class="header">{self.header_text}</div>'
361
+
362
+ def _build_footer(self) -> str:
363
+ footer_html = '<div class="footer">'
364
+ if self.confidential:
365
+ footer_html += '<span class="confidential-mark">CONFIDENTIAL</span>'
366
+ footer_html += '</div>'
367
+ return footer_html
368
+
369
+ def _build_cover_page(self) -> str:
370
+ return f"""
371
+ <div class="cover-page">
372
+ <div class="cover-title">{self.title}</div>
373
+ <div class="cover-divider"></div>
374
+ <div class="cover-subtitle">{self.subtitle}</div>
375
+ <div class="cover-meta">{self.department}</div>
376
+ <div class="cover-meta">Prepared by: {self.author}</div>
377
+ <div class="cover-date">{self.date}</div>
378
+ </div>
379
+ """
380
+
381
+ def _process_section(self, section: Dict, output_dir: str) -> str:
382
+ html = ""
383
 
384
+ content = section.get("content", "")
385
+ if content:
386
+ html_content = markdown.markdown(
387
+ content,
388
+ extensions=['extra', 'sane_lists', 'tables', 'fenced_code']
389
+ )
390
+ html += f"<div class='section'>{html_content}</div>"
391
 
392
+ images = section.get("images", [])
393
+ for img_data in images:
394
+ if isinstance(img_data, dict):
395
+ img_url = img_data.get("url", "")
396
+ caption = img_data.get("caption", "Figure: Visualization")
397
+ else:
398
+ img_url = img_data
399
+ caption = "Figure: Visualization"
400
+
401
+ if img_url:
402
+ file_uri = fetch_image(img_url, output_dir)
403
+ if file_uri:
404
+ self.downloaded_images.append(file_uri)
405
+ html += f"""
406
+ <div class="figure-container">
407
+ <img src="{file_uri}" alt="{caption}">
408
+ <div class="figure-caption">{caption}</div>
409
+ </div>
410
+ """
411
 
412
+ if section.get("page_break", False):
413
+ html += '<div class="page-break"></div>'
 
414
 
415
+ return html
416
+
417
+ def generate(self,
418
+ sections: List[Dict],
419
+ filename: str = "Q4_Report.pdf",
420
+ output_dir: str = "./output",
421
+ cover_page: bool = True) -> bool:
422
  try:
423
+ os.makedirs(output_dir, exist_ok=True)
424
+ output_path = os.path.join(output_dir, filename)
 
425
 
426
+ logger.info(f"Starting Q4 report generation: {output_path}")
427
+
428
+ body_html = ""
429
+ if cover_page:
430
+ body_html += self._build_cover_page()
431
+
432
+ for idx, section in enumerate(sections):
433
+ logger.info(f"Processing section {idx + 1}/{len(sections)}")
434
+ body_html += self._process_section(section, output_dir)
435
+
436
+ full_html = f"""
437
+ <!DOCTYPE html>
438
+ <html>
439
+ <head>
440
+ <meta charset="UTF-8">
441
+ <title>{self.title}</title>
442
+ </head>
443
+ <body>
444
+ {self._build_header()}
445
+ {self._build_footer()}
446
+ {body_html}
447
+ </body>
448
+ </html>
449
+ """
450
+
451
+ logger.info("Rendering PDF with WeasyPrint...")
452
+ html_doc = HTML(string=full_html)
453
+ css_doc = CSS(string=CSS_STYLE)
454
+
455
+ html_doc.write_pdf(output_path, stylesheets=[css_doc])
456
+
457
+ abs_path = os.path.abspath(output_path)
458
+ file_size = os.path.getsize(output_path) / 1024
459
+
460
+ print(f"\n{'='*70}")
461
+ print(f"βœ… PDF GENERATED SUCCESSFULLY!")
462
+ print(f"{'='*70}")
463
+ print(f"πŸ“ Location: {abs_path}")
464
+ print(f"πŸ“Š Size: {file_size:.2f} KB")
465
+ print(f"πŸ–ΌοΈ Images: {len(self.downloaded_images)} embedded")
466
+ print(f"{'='*70}\n")
467
+
468
+ return True
469
+
470
+ except Exception as e:
471
+ logger.error(f"❌ Error generating report: {e}", exc_info=True)
472
+ return False
473
 
474
  # ---------------------------------------------------------
475
  # COMPLETE TEST CASE WITH FULL Q4 REPORT