Soumik Bose commited on
Commit
7db7163
·
1 Parent(s): a6e6a7c
Files changed (2) hide show
  1. controller.py +32 -11
  2. pdf_report_generation_service.py +161 -440
controller.py CHANGED
@@ -780,21 +780,35 @@ async def get_chat_log_endpoint(payload: ChatLogRequest, token: str = Depends(va
780
  @app.post("/api/generate_pdf_report", response_model=ReportGenerationResponse)
781
  async def generate_pdf_report_endpoint(
782
  payload: ReportGenerationRequest,
783
- token: str = Depends(validate_token) # Assuming validate_token is defined in your main file
784
  ):
785
  """
786
- Generates a PDF report, uploads it to Supabase via the helper,
787
- and returns the public URL.
 
 
 
 
 
 
 
788
  """
789
  request_id = str(uuid.uuid4())[:8]
790
 
791
  try:
792
- # Convert Pydantic models to a list of dictionaries for the helper
793
- # .model_dump() is for Pydantic v2, use .dict() for v1
794
- config_content = [section.dict() for section in payload.sections]
 
 
 
 
 
 
 
 
795
 
796
- # Call the helper function directly.
797
- # It handles generation, upload, and cleanup.
798
  public_url = await generate_and_upload_report(
799
  config_content=config_content,
800
  file_name=payload.file_name,
@@ -806,15 +820,22 @@ async def generate_pdf_report_endpoint(
806
  output_dir="temp_reports"
807
  )
808
 
 
 
 
 
 
 
 
809
  return ReportGenerationResponse(
810
  success=True,
811
  pdf_report_file_url=public_url,
812
- file_name=f"{payload.file_name}.pdf" if not payload.file_name.endswith('.pdf') else payload.file_name,
813
  request_id=request_id
814
  )
815
-
816
  except Exception as e:
817
- logger.error(f"Report Generation Failed: {e}")
818
  return ReportGenerationResponse(
819
  success=False,
820
  error=str(e),
 
780
  @app.post("/api/generate_pdf_report", response_model=ReportGenerationResponse)
781
  async def generate_pdf_report_endpoint(
782
  payload: ReportGenerationRequest,
783
+ token: str = Depends(validate_token)
784
  ):
785
  """
786
+ Generates a PDF report, uploads it to Supabase, and returns the public URL.
787
+
788
+ This endpoint:
789
+ 1. Validates the request payload
790
+ 2. Converts Pydantic models to dictionaries
791
+ 3. Generates the PDF with proper formatting
792
+ 4. Uploads to Supabase storage
793
+ 5. Cleans up temporary files
794
+ 6. Returns the public URL
795
  """
796
  request_id = str(uuid.uuid4())[:8]
797
 
798
  try:
799
+ logger.info(f"[{request_id}] Starting PDF report generation for chat: {payload.chat_id}")
800
+
801
+ # Convert Pydantic models to dictionaries for the generator
802
+ # Use .model_dump() for Pydantic v2, .dict() for v1
803
+ try:
804
+ config_content = [section.model_dump() for section in payload.sections]
805
+ except AttributeError:
806
+ # Fallback for Pydantic v1
807
+ config_content = [section.dict() for section in payload.sections]
808
+
809
+ logger.info(f"[{request_id}] Processing {len(config_content)} sections")
810
 
811
+ # Call the async helper function
 
812
  public_url = await generate_and_upload_report(
813
  config_content=config_content,
814
  file_name=payload.file_name,
 
820
  output_dir="temp_reports"
821
  )
822
 
823
+ # Prepare final filename
824
+ final_filename = payload.file_name
825
+ if not final_filename.endswith('.pdf'):
826
+ final_filename += '.pdf'
827
+
828
+ logger.info(f"[{request_id}] Report generated successfully: {public_url}")
829
+
830
  return ReportGenerationResponse(
831
  success=True,
832
  pdf_report_file_url=public_url,
833
+ file_name=final_filename,
834
  request_id=request_id
835
  )
836
+
837
  except Exception as e:
838
+ logger.error(f"[{request_id}] Report generation failed: {e}", exc_info=True)
839
  return ReportGenerationResponse(
840
  success=False,
841
  error=str(e),
pdf_report_generation_service.py CHANGED
@@ -1,475 +1,196 @@
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
 
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
+ ) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  """
94
+ Generates a PDF report from content configuration, uploads it to Supabase,
95
+ and returns the public URL. Cleans up temporary files afterwards.
96
 
97
  Args:
98
+ config_content: List of section dictionaries for the report
99
+ file_name: Desired filename (without or with .pdf extension)
100
+ chat_id: Chat ID for Supabase storage organization
101
+ title: Report title
102
+ subtitle: Report subtitle
103
+ author: Report author
104
+ department: Department name
105
+ output_dir: Directory for temporary file storage
106
 
107
  Returns:
108
+ str: Public URL of the uploaded PDF
109
+
110
+ Raises:
111
+ Exception: If PDF generation or upload fails
112
  """
113
+
114
+ local_file_path = None
 
 
 
115
 
116
  try:
117
+ # 1. Setup paths
118
+ os.makedirs(output_dir, exist_ok=True)
119
 
120
+ # Ensure filename ends with .pdf
121
+ if not file_name.endswith('.pdf'):
122
+ file_name += '.pdf'
 
 
123
 
124
+ # Create unique filename to prevent collisions
125
+ unique_id = str(uuid.uuid4())[:8]
126
+ local_filename = f"{os.path.splitext(file_name)[0]}_{unique_id}.pdf"
127
+ local_file_path = os.path.join(output_dir, local_filename)
 
 
 
 
 
 
 
128
 
129
+ logger.info(f"Starting PDF generation: {local_filename}")
 
 
130
 
131
+ # 2. Initialize Generator
132
+ generator = Q4ReportGenerator(
133
+ title=title,
134
+ subtitle=subtitle,
135
+ author=author,
136
+ department=department,
137
+ confidential=True
138
+ )
139
 
140
+ # 3. Generate PDF (Run in thread pool to avoid blocking)
141
+ success = await run_in_threadpool(
142
+ _generate_pdf_sync,
143
+ generator=generator,
144
+ config_content=config_content,
145
+ local_file_path=local_file_path,
146
+ output_dir=output_dir
147
+ )
148
 
149
+ if not success:
150
+ raise Exception("PDF generation failed - generator returned False")
151
 
152
+ if not os.path.exists(local_file_path):
153
+ raise Exception(f"PDF file not found after generation: {local_file_path}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
 
155
+ # 4. Upload to Supabase (Run in thread pool)
156
+ cloud_filename = f"{os.path.splitext(file_name)[0]}_{unique_id}.pdf"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
 
158
+ logger.info(f"Uploading {local_filename} to Supabase as {cloud_filename}")
 
 
 
 
 
 
159
 
160
+ public_url = await run_in_threadpool(
161
+ _upload_to_supabase_sync,
162
+ file_path=local_file_path,
163
+ file_name=cloud_filename,
164
+ chat_id=chat_id
165
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
166
 
167
+ if not public_url:
168
+ raise Exception("Upload failed - no URL returned")
169
 
170
+ logger.info(f"Upload successful: {public_url}")
171
+
172
+ return public_url
173
+
174
+ except Exception as e:
175
+ logger.error(f"Report generation/upload failed: {e}", exc_info=True)
176
+ raise Exception(f"Failed to generate and upload report: {str(e)}")
177
+
178
+ finally:
179
+ # 5. Cleanup - Always execute, even if there's an error
180
  try:
181
+ # Delete local PDF
182
+ if local_file_path:
183
+ _cleanup_files([local_file_path])
184
 
185
+ # Delete downloaded images
186
+ if 'generator' in locals():
187
+ await run_in_threadpool(
188
+ _cleanup_downloaded_images,
189
+ generator=generator,
190
+ output_dir=output_dir
191
+ )
192
+ except Exception as cleanup_error:
193
+ logger.warning(f"Cleanup error (non-fatal): {cleanup_error}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
 
195
  # ---------------------------------------------------------
196
  # COMPLETE TEST CASE WITH FULL Q4 REPORT